From d2298a11cfa23213bdd7f15717339c9c792eb5c8 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Fri, 16 Oct 2026 11:12:31 +0000 Subject: [PATCH 1/2] Part 1: Booking Assistant updates with A2UI Compose runtime and SSE streaming - Implement Jetpack A2UI Compose runtime and integrate dynamic SSE streaming - Extract and modularize 15 custom A2UI components into dedicated catalog package - Modularize Python ADK booking server into domain agents (flight, hotel, activity, orchestrator, base) - Enhance Booking Assistant chat UI with Material3 theming, auto-mirrored icons, and dedicated composers - Add BookingAssistantUiState, BookingEventNormalizer, and BookingIntentClassifier - Sanitize project credentials, Cloud Run endpoints, and gateway URLs with generic placeholders --- .../trip/booking_assistant/build.gradle.kts | 12 +- .../BookingAssistantScreen.kt | 465 ++++---- .../BookingAssistantViewModel.kt | 1005 +++++++---------- .../CustomBookingAssistantCatalog.kt | 807 +------------ .../catalog/AircraftCabinSeatMapComponent.kt | 677 +++++++++++ .../catalog/BookingConfirmationComponent.kt | 261 +++++ .../catalog/BookingStatusComponent.kt | 263 +++++ .../catalog/CatalogActions.kt | 32 + .../catalog/CatalogColorTokens.kt | 28 + .../catalog/DigitalHotelKeyPassComponent.kt | 574 ++++++++++ .../catalog/FlightAddonsCardComponent.kt | 348 ++++++ .../FlightConfiguratorShellComponent.kt | 591 ++++++++++ .../catalog/FlightOptionSelectorComponent.kt | 461 ++++++++ .../HotelConfiguratorShellComponent.kt | 417 +++++++ .../HotelLiveSummaryFooterComponent.kt | 318 ++++++ .../catalog/HotelRoomSelectorComponent.kt | 424 +++++++ .../catalog/HotelStayCustomizerComponent.kt | 403 +++++++ .../InteractiveOptionPickerComponent.kt | 350 ++++++ .../catalog/LiveFareSummaryFooterComponent.kt | 335 ++++++ .../PerforatedBoardingPassComponent.kt | 667 +++++++++++ .../catalog/SeatSelectionPickerComponent.kt | 321 ++++++ .../components/BookingChatComposer.kt | 140 +++ .../components/ChatMessageItem.kt | 149 +++ .../components/JourneyCategoryCard.kt | 186 +++ .../domain/BookingAssistantUiState.kt | 31 + .../domain/BookingEventNormalizer.kt | 208 ++++ .../domain/BookingIntentClassifier.kt | 186 +++ .../domain/BookingDirectiveParserTest.kt | 57 + .../domain/BookingEventNormalizerTest.kt | 71 ++ .../domain/BookingIntentClassifierTest.kt | 128 +++ .../android/feature/trip/build.gradle.kts | 7 +- jetpacker/android/gradle/libs.versions.toml | 2 +- jetpacker/server/agents/__init__.py | 27 + jetpacker/server/agents/activity.py | 463 ++++++++ jetpacker/server/agents/base.py | 182 +++ jetpacker/server/agents/flight.py | 929 +++++++++++++++ jetpacker/server/agents/hotel.py | 690 +++++++++++ jetpacker/server/agents/orchestrator.py | 371 ++++++ jetpacker/server/booking/__init__.py | 9 +- jetpacker/server/booking/agent.py | 9 +- jetpacker/server/main.py | 584 +--------- jetpacker/server/openapi.yaml | 20 +- jetpacker/server/schemas.py | 83 ++ 43 files changed, 11112 insertions(+), 2179 deletions(-) create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/AircraftCabinSeatMapComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingConfirmationComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingStatusComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogColorTokens.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/DigitalHotelKeyPassComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightAddonsCardComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightConfiguratorShellComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightOptionSelectorComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelConfiguratorShellComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelLiveSummaryFooterComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelRoomSelectorComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelStayCustomizerComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/InteractiveOptionPickerComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/LiveFareSummaryFooterComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/PerforatedBoardingPassComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/SeatSelectionPickerComponent.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/BookingChatComposer.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/ChatMessageItem.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/JourneyCategoryCard.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingAssistantUiState.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizer.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifier.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingDirectiveParserTest.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizerTest.kt create mode 100644 jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifierTest.kt create mode 100644 jetpacker/server/agents/__init__.py create mode 100644 jetpacker/server/agents/activity.py create mode 100644 jetpacker/server/agents/base.py create mode 100644 jetpacker/server/agents/flight.py create mode 100644 jetpacker/server/agents/hotel.py create mode 100644 jetpacker/server/agents/orchestrator.py create mode 100644 jetpacker/server/schemas.py diff --git a/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts b/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts index 487fb80b..154f7da8 100644 --- a/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts +++ b/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts @@ -23,7 +23,12 @@ plugins { android { namespace = "com.example.jetpacker.feature.booking_assistant" - compileSdk = libs.versions.compileSdk.get().toInt() + compileSdk { + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -63,6 +68,11 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth.ktx) implementation(libs.kotlinx.coroutines.play.services) + + testImplementation(libs.junit) + testImplementation(libs.google.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation("org.json:json:20240303") } kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt index 4f4d6f99..643ff55c 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt @@ -14,41 +14,33 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) + package com.example.jetpacker.feature.booking_assistant -import androidx.a2ui.model.processor.A2uiSurfaceModel -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.ConfirmationNumber import androidx.compose.material.icons.rounded.Flight -import androidx.compose.material.icons.rounded.Hotel -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Restaurant -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -66,17 +58,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.feature.booking_assistant.components.BookingChatComposer +import com.example.jetpacker.feature.booking_assistant.components.ChatMessageItem +import com.example.jetpacker.feature.booking_assistant.components.JourneyCategoryCard +import com.example.jetpacker.feature.booking_assistant.components.JourneySubItemRow -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BookingAssistantScreen( tripId: String, @@ -85,22 +82,20 @@ fun BookingAssistantScreen( viewModel: BookingAssistantViewModel = hiltViewModel(), modifier: Modifier = Modifier, ) { - val activeSurfaces by viewModel.activeSurfaces.collectAsStateWithLifecycle() - val isStreaming by viewModel.isStreaming.collectAsStateWithLifecycle() - val surfaceStatuses by viewModel.surfaceStatuses.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var inputText by remember { mutableStateOf("") } + val listState = rememberLazyListState() - val groupedSurfaces = remember(activeSurfaces) { - activeSurfaces - .groupBy { BookingCategory.fromCategoryName(viewModel.getCategory(it.id)) } - .toSortedMap(compareBy { it.order }) - } + LaunchedEffect(tripId) { viewModel.initializeChat(tripId) } - var expandedCategories by remember { - mutableStateOf(emptySet()) - } - - LaunchedEffect(tripId) { - viewModel.startBooking(tripId) + // Auto-scroll to the latest message or newly streamed surface + LaunchedEffect(uiState.chatMessages.size, uiState.activeSurfaces.size) { + if (uiState.chatMessages.isNotEmpty()) { + val totalItems = listState.layoutInfo.totalItemsCount + if (totalItems > 0) { + listState.animateScrollToItem(totalItems - 1) + } + } } Scaffold( @@ -110,23 +105,32 @@ fun BookingAssistantScreen( Column(modifier = Modifier.background(MaterialTheme.colorScheme.primary)) { TopAppBar( title = { - Text( - text = "MANAGE BOOKINGS", - style = - MaterialTheme.typography.titleLarge.copy( - fontFamily = SekuyaFontFamily, - fontSize = 24.sp, - letterSpacing = 0.5.sp, - ), - color = Color(0xFF1E293B), - ) + Column { + Text( + text = uiState.trip?.title?.uppercase() ?: "ITINERARY", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + ), + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + uiState.trip?.location?.let { location -> + Text( + text = location, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back", - tint = Color(0xFF1E293B), + tint = MaterialTheme.colorScheme.onPrimary, ) } }, @@ -134,19 +138,17 @@ fun BookingAssistantScreen( IconButton(onClick = { viewModel.resetBookings() }) { Icon( imageVector = Icons.Rounded.Refresh, - contentDescription = "Reset Bookings", - tint = Color(0xFF1E293B).copy(alpha = 0.4f), + contentDescription = "Reset Chat", + tint = MaterialTheme.colorScheme.onPrimary, ) } }, colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary), ) - if (isStreaming) { + if (uiState.isStreaming) { LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(3.dp), color = MaterialTheme.colorScheme.secondary, trackColor = Color.Transparent, ) @@ -154,258 +156,175 @@ fun BookingAssistantScreen( } }, ) { innerPadding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), - contentPadding = - PaddingValues( - top = innerPadding.calculateTopPadding() + 8.dp, - bottom = contentPadding.calculateBottomPadding() + 24.dp, - ), - verticalArrangement = Arrangement.spacedBy(16.dp), + val bottomBarPadding = contentPadding.calculateBottomPadding() + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + val effectiveBottomPadding = maxOf(bottomBarPadding, imeBottomPadding) + + Column( + modifier = + Modifier.fillMaxSize() + .padding(top = innerPadding.calculateTopPadding(), bottom = effectiveBottomPadding) ) { - item { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "Current Itinerary •", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - color = Color(0xFF1E293B), - ) - Text( - text = "YOUR NEXT JOURNEY", - style = - MaterialTheme.typography.headlineMedium.copy( - fontFamily = SekuyaFontFamily, - fontSize = 24.sp, - letterSpacing = 0.5.sp, - ), - color = Color(0xFF1E293B), - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = - "Review and finalize your travel segments. We've organized your selections for a seamless booking.", - style = - MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - lineHeight = 20.sp, - ), - color = Color(0xFF334155), - ) - } + val linkedSurfaceIds = remember(uiState.chatMessages) { + uiState.chatMessages.mapNotNull { it.surfaceId }.toSet() + } + val unlinkedSurfaces = remember(uiState.activeSurfaces, linkedSurfaceIds) { + uiState.activeSurfaces.filter { it.id !in linkedSurfaceIds } } - if (activeSurfaces.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(top = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "No active booking surfaces.\nPress 'Start Booking' to initialize.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, + LazyColumn( + state = listState, + modifier = + Modifier.weight(1f) + .fillMaxWidth() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.94f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, ) - Button( - onClick = { viewModel.resetBookings() }, - shape = RoundedCornerShape(12.dp), - ) { - Text("Start Booking", fontWeight = FontWeight.Bold) - } } - } + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 12.dp, bottom = 52.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items(items = uiState.chatMessages, key = { it.id }) { message -> + val linkedSurface = + remember(message.surfaceId, uiState.activeSurfaces) { + message.surfaceId?.let { id -> uiState.activeSurfaces.find { it.id == id } } + } + ChatMessageItem(message = message, surfaceModel = linkedSurface) + } + + items(items = unlinkedSurfaces, key = { "unlinked_surface_${it.id}" }) { surface -> + A2uiSurface(surfaceModel = surface, modifier = Modifier.fillMaxWidth()) } - } else { - groupedSurfaces.forEach { (category, surfaces) -> - item(key = category.name) { - CategoryCard( - category = category, - surfaces = surfaces, - subtitle = viewModel.getCategorySubtitle(surfaces), - isExpanded = expandedCategories.contains(category), - onToggleExpand = { - expandedCategories = - if (expandedCategories.contains(category)) { - expandedCategories - category - } else { - expandedCategories + category - } + + if (uiState.chatMessages.none { it.isUser } && uiState.activeSurfaces.isEmpty()) { + item(key = "your_next_journey_overview") { + YourNextJourneySection( + flightItems = uiState.flightJourneyItems, + activityItems = uiState.activityJourneyItems, + onSelectItem = { item -> + viewModel.sendChatMessage( + prompt = item.prompt, + intentOverride = item.intent, + targetTitleOverride = item.targetTitle, + ) }, + modifier = Modifier.padding(horizontal = 8.dp), ) } } } + + BookingChatComposer( + inputText = inputText, + onInputTextChange = { inputText = it }, + onSendMessage = { prompt -> viewModel.sendChatMessage(prompt) }, + isStreaming = uiState.isStreaming, + ) } } } @Composable -private fun CategoryCard( - category: BookingCategory, - surfaces: List, - subtitle: String, - isExpanded: Boolean, - onToggleExpand: () -> Unit, +private fun YourNextJourneySection( + flightItems: List, + activityItems: List, + onSelectItem: (BookableJourneyItem) -> Unit, modifier: Modifier = Modifier, ) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = - CardDefaults.cardColors( - containerColor = Color(0xFFFFFDF5), - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(20.dp), - ) { + var flightsExpanded by remember { mutableStateOf(false) } + var activitiesExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Row( - modifier = - Modifier - .fillMaxWidth() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onToggleExpand, - ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Box( - modifier = - Modifier - .size(48.dp) - .background(category.circleColor, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = category.icon, - contentDescription = category.displayName, - tint = Color.White, - modifier = Modifier.size(24.dp), - ) - } + Text( + text = "Trip Concierge", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "•", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "FINALIZE YOUR TRIP", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } - Column(modifier = Modifier.weight(1f)) { - Text( - text = "${category.displayName} (${surfaces.size})", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ), - color = Color(0xFF1E293B), - ) - Text( - text = subtitle, - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 13.sp, - ), - color = Color(0xFF64748B), - ) - } + Text( + text = + "Complete your pending reservations below, or ask to customize your stay, seats, or dining.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - Box( - modifier = - Modifier - .size(36.dp) - .background(Color.White, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = - if (isExpanded) Icons.Rounded.KeyboardArrowUp - else Icons.Rounded.KeyboardArrowDown, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = Color(0xFF1E293B), - modifier = Modifier.size(20.dp), - ) - } - } + Spacer(modifier = Modifier.height(2.dp)) - AnimatedVisibility(visible = isExpanded) { + if (flightItems.isNotEmpty()) { + JourneyCategoryCard( + title = "Flights (${flightItems.size})", + subtitle = "Action required", + icon = Icons.Rounded.Flight, + expanded = flightsExpanded, + onToggle = { flightsExpanded = !flightsExpanded }, + ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 16.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - surfaces.forEachIndexed { index, surfaceModel -> - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 16.dp), - color = Color(0xFFE5E7EB), - thickness = 1.dp, - ) - } - A2uiSurface( - surfaceModel = surfaceModel, - modifier = Modifier.fillMaxWidth(), + flightItems.forEach { item -> + JourneySubItemRow( + title = item.title, + subtitle = item.subtitle, + ctaText = item.ctaText, + onClick = { onSelectItem(item) }, ) } } } } - } -} - -enum class BookingCategory( - val displayName: String, - val order: Int, -) { - FLIGHT("Flights", 1), - HOTEL("Hotels", 2), - ACTIVITY("Activities", 3), - DINING("Dining", 4), - OTHER("Other", 5); - - val icon: ImageVector - get() = - when (this) { - FLIGHT -> Icons.Rounded.Flight - HOTEL -> Icons.Rounded.Hotel - ACTIVITY -> Icons.Rounded.ConfirmationNumber - DINING -> Icons.Rounded.Restaurant - OTHER -> Icons.Rounded.ConfirmationNumber - } - val circleColor: Color - get() = - when (this) { - FLIGHT -> Color(0xFFF05123) - HOTEL -> Color(0xFF2563EB) - ACTIVITY -> Color(0xFF059669) - DINING -> Color(0xFFD97706) - OTHER -> Color(0xFF64748B) - } - - companion object { - fun fromCategoryName(name: String): BookingCategory { - return when (name.lowercase()) { - "flight", "flights" -> FLIGHT - "hotel", "hotels" -> HOTEL - "activity", "activities" -> ACTIVITY - "dining", "dining & restaurants" -> DINING - else -> OTHER + if (activityItems.isNotEmpty()) { + JourneyCategoryCard( + title = "Activities (${activityItems.size})", + subtitle = "Action required", + icon = Icons.Rounded.ConfirmationNumber, + expanded = activitiesExpanded, + onToggle = { activitiesExpanded = !activitiesExpanded }, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + activityItems.forEach { item -> + JourneySubItemRow( + title = item.title, + subtitle = item.subtitle, + ctaText = item.ctaText, + onClick = { onSelectItem(item) }, + ) + } + } } } } diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt index e16b2772..636aebcf 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt @@ -17,23 +17,29 @@ package com.example.jetpacker.feature.booking_assistant import android.util.Log -import androidx.a2ui.compose.ui.A2uiMessageProcessor +import androidx.a2ui.compose.runtime.A2uiMessageParser +import androidx.a2ui.compose.ui.A2uiMessageProcessor as createA2uiMessageProcessor import androidx.a2ui.model.processor.A2uiMessageProcessor import androidx.a2ui.model.processor.A2uiSurfaceModel +import androidx.a2ui.model.processor.processInput import androidx.a2ui.model.protocol.A2uiClientEventMessage import androidx.a2ui.model.protocol.A2uiComponentPayload import androidx.a2ui.model.protocol.A2uiCreateSurfaceMessage import androidx.a2ui.model.protocol.A2uiUpdateComponentsMessage -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.a2ui.model.protocol.A2uiUpdateDataModelMessage import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.FlightDetail import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import com.example.jetpacker.feature.booking_assistant.domain.BookingAssistantUiState +import com.example.jetpacker.feature.booking_assistant.domain.BookingEventNormalizer +import com.example.jetpacker.feature.booking_assistant.domain.BookingIntentClassifier import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel import java.io.BufferedReader @@ -42,12 +48,15 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay 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.tasks.await +import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -55,88 +64,60 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject -data class SurfaceStatus( - val surfaceId: String, - val type: String, - val status: String, +data class BookingChatMessage( + val id: String, + val isUser: Boolean, + val text: String, + val surfaceId: String? = null, ) +data class BookableJourneyItem( + val id: String, + val title: String, + val subtitle: String, + val ctaText: String, + val prompt: String, + val intent: String, + val targetTitle: String, +) + +// Configure with your deployed Cloud Run / API Gateway URL, or "http://10.0.2.2:8000" / "http://localhost:8000" for local testing. +private const val DEFAULT_BACKEND_URL = "https://YOUR_BACKEND_GATEWAY_URL/v2" + @HiltViewModel -class BookingAssistantViewModel @Inject constructor( +class BookingAssistantViewModel +@Inject +constructor( private val savedStateHandle: SavedStateHandle, private val eventDao: EventDao, + private val tripDao: TripDao, ) : ViewModel() { - private val messageProcessor: A2uiMessageProcessor = A2uiMessageProcessor( - catalogs = listOf(bookingAssistantCatalog()) - ) + private val _uiState = MutableStateFlow(BookingAssistantUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - val activeSurfaces: StateFlow> = messageProcessor.activeSurfaces + private val a2uiParser = A2uiMessageParser() + private val messageProcessor: A2uiMessageProcessor = + createA2uiMessageProcessor(catalogs = listOf(bookingAssistantCatalog())) private var tripEvents: List = emptyList() private var currentTripId: String? = null private var activeStreamJob: Job? = null + private var activeStreamCall: Call? = null private var streamIdleJob: Job? = null - enum class BackendEndpoint { - LOCAL, - PARALLEL_STAGING, - PRODUCTION, - } - - /** - * Active backend endpoint. Defaults to [BackendEndpoint.PARALLEL_STAGING] to connect to the - * side-by-side /v2 Cloud Run service without impacting production. - */ - var backendEndpoint by mutableStateOf(BackendEndpoint.PARALLEL_STAGING) - - // Backwards compatibility property - var useLocalServer: Boolean - get() = backendEndpoint == BackendEndpoint.LOCAL - set(value) { - backendEndpoint = if (value) BackendEndpoint.LOCAL else BackendEndpoint.PARALLEL_STAGING - } - - val baseUrl: String - get() = - when (backendEndpoint) { - BackendEndpoint.LOCAL -> "http://localhost:8000" - BackendEndpoint.PARALLEL_STAGING -> "https://jetset-gateway-38wzy18y.uc.gateway.dev/v2" - BackendEndpoint.PRODUCTION -> "https://jetset-gateway-38wzy18y.uc.gateway.dev" - } - private val client = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() - private var sessionId: String? - get() = savedStateHandle["session_id"] - set(value) { - savedStateHandle["session_id"] = value - } - - private var streamToken: String? - get() = savedStateHandle["stream_token"] - set(value) { - savedStateHandle["stream_token"] = value - } - - private var streamUrl: String? - get() = savedStateHandle["stream_url"] - set(value) { - savedStateHandle["stream_url"] = value - } - - private val _isStreaming = MutableStateFlow(false) - val isStreaming: StateFlow = _isStreaming.asStateFlow() - - private val _surfaceStatuses = MutableStateFlow>(emptyMap()) - val surfaceStatuses: StateFlow> = _surfaceStatuses.asStateFlow() - - private val _logs = MutableStateFlow>(emptyList()) - val logs: StateFlow> = _logs.asStateFlow() + private var sessionId: String? = null + private var streamToken: String? = null + private var streamUrl: String? = null init { + viewModelScope.launch { messageProcessor.collectMessages() } viewModelScope.launch { - messageProcessor.collectMessages() + messageProcessor.activeSurfaces.collect { surfaces -> + _uiState.update { it.copy(activeSurfaces = surfaces) } + } } viewModelScope.launch { messageProcessor.outboundEvents.collect { message -> @@ -147,227 +128,213 @@ class BookingAssistantViewModel @Inject constructor( } } - private fun resetStreamIdleTimer(delayMillis: Long = 2500L) { - streamIdleJob?.cancel() - streamIdleJob = viewModelScope.launch { - kotlinx.coroutines.delay(delayMillis) - _isStreaming.value = false - } - } - - fun startBooking(tripId: String, forceRestart: Boolean = false) { - if (currentTripId == tripId && activeStreamJob?.isActive == true && !forceRestart) return + fun initializeChat(tripId: String, forceReset: Boolean = false) { + if (currentTripId == tripId && !forceReset) return currentTripId = tripId - savedStateHandle["tripId"] = tripId - activeStreamJob?.cancel() - streamIdleJob?.cancel() - activeStreamJob = viewModelScope.launch(Dispatchers.IO) { - try { - _isStreaming.value = true - log("Loading trip events for trip: $tripId") - - val dbEvents = runCatching { eventDao.getEventsForTrip(tripId).first() }.getOrNull() ?: emptyList() - tripEvents = if (dbEvents.isNotEmpty()) { - dbEvents - } else { - DummyData.events.filter { it.tripId == tripId }.ifEmpty { - DummyData.events.take(4) + viewModelScope.launch { + val loadedTrip = tripDao.getTripById(tripId).first() + val rawEvents = eventDao.getEventsForTrip(tripId).first() + tripEvents = BookingEventNormalizer.normalizeBookableEvents(rawEvents) + + val flightItems = + tripEvents + .filter { it.type == EventType.TRANSPORTATION } + .map { event -> + BookableJourneyItem( + id = event.id, + title = event.title, + subtitle = "Flight booking pending", + ctaText = "Select seat", + prompt = "Book flight ${event.title}", + intent = "flight", + targetTitle = event.title, + ) } - } - // Initialize A2UI surfaces for each itinerary event - tripEvents.forEach { event -> - val cat = when (event.type) { - EventType.TRANSPORTATION -> "Flight" - EventType.ACCOMMODATION -> "Hotel" - EventType.ACTIVITY, EventType.CULTURE -> "Activity" - EventType.FOOD_AND_DRINK -> "Dining" - else -> "Booking" + val activityItems = + tripEvents + .filter { + it.type == EventType.ACTIVITY || + it.type == EventType.CULTURE || + it.type == EventType.FOOD_AND_DRINK || + it.type == EventType.ACCOMMODATION + } + .map { event -> + val cta = + when (event.type) { + EventType.ACCOMMODATION -> "Select room" + EventType.FOOD_AND_DRINK -> "Reserve table" + else -> "Book tickets" + } + val intent = + when (event.type) { + EventType.ACCOMMODATION -> "hotel" + EventType.FOOD_AND_DRINK -> "dining" + else -> "activity" + } + BookableJourneyItem( + id = event.id, + title = event.title, + subtitle = event.location.ifEmpty { "Reservation pending" }, + ctaText = cta, + prompt = "Book ${event.title}", + intent = intent, + targetTitle = event.title, + ) } - upsertSurface( - surfaceId = event.title, - payload = A2uiComponentPayload( - id = "root", - type = "BookingStatus", - properties = mapOf( - "category" to cat, - "title" to event.title, - "description" to (event.description?.ifEmpty { "Waiting for coordinator..." } ?: "Waiting for coordinator..."), - "status" to "Queued", - ), - ), - ) + + _uiState.update { + it.copy( + trip = loadedTrip, + flightJourneyItems = flightItems, + activityJourneyItems = activityItems, + chatMessages = + if (forceReset) emptyList() + else it.chatMessages.ifEmpty { + listOf( + BookingChatMessage( + id = "welcome", + isUser = false, + text = + "Hi! I'm your Trip Concierge. Let's finish booking your flights, stays, and activities for ${loadedTrip?.title ?: "your trip"}.", + ) + ) + }, + ) + } + } + } + + fun sendChatMessage( + prompt: String, + intentOverride: String? = null, + targetTitleOverride: String? = null, + ) { + val cleanPrompt = prompt.trim() + log("sendChatMessage: prompt='$cleanPrompt'") + if (cleanPrompt.isEmpty() || _uiState.value.isStreaming) { + log("sendChatMessage: early return (empty=${cleanPrompt.isEmpty()}, streaming=${_uiState.value.isStreaming})") + return + } + + val userMsg = + BookingChatMessage( + id = System.currentTimeMillis().toString(), + isUser = true, + text = cleanPrompt, + ) + + _uiState.update { it.copy(chatMessages = it.chatMessages + userMsg, isStreaming = true) } + resetStreamIdleTimer() + + viewModelScope.launch(Dispatchers.IO) { + val sId = sessionId ?: "session_${System.currentTimeMillis()}".also { sessionId = it } + val directMatch = + tripEvents.find { + it.title.equals(cleanPrompt, ignoreCase = true) || + cleanPrompt.contains(it.title, ignoreCase = true) } + val resolvedIntent = + BookingIntentClassifier.resolveIntent(cleanPrompt, directMatch, intentOverride) + val resolvedTarget = + BookingIntentClassifier.resolveTargetTitle( + cleanPrompt, + resolvedIntent, + tripEvents, + directMatch, + targetTitleOverride, + ) - val sId = if (forceRestart) { - System.currentTimeMillis().toString().also { sessionId = it } - } else { - sessionId ?: System.currentTimeMillis().toString().also { sessionId = it } + if (resolvedTarget.isNotEmpty()) { + runCatching { + val deleteJson = + """{"version":"v0.9","deleteSurface":{"surfaceId":${JSONObject.quote(resolvedTarget)}}}""" + messageProcessor.processInput(a2uiParser, deleteJson) } + } - log("Surfaces ready for trip $tripId, sId=$sId. Connecting to stream...") - connectToStream(sId) - log("connectToStream completed.") + try { + connectToStream( + sId = sId, + command = "chat", + prompt = cleanPrompt, + intent = resolvedIntent, + targetTitle = resolvedTarget, + ) } catch (e: Exception) { - log("Error starting booking: ${e.message}") - Log.e("BookingAssistant", "Exception in startBooking", e) - _isStreaming.value = false + log("Error in sendChatMessage stream: ${e.message}") + _uiState.update { it.copy(isStreaming = false) } } } } fun resetBookings() { val tripId = currentTripId ?: savedStateHandle["tripId"] ?: "2026-1" + activeStreamJob?.cancel() + activeStreamJob = null + activeStreamCall?.cancel() + activeStreamCall = null + streamIdleJob?.cancel() + streamIdleJob = null sessionId = null streamToken = null streamUrl = null - streamIdleJob?.cancel() - _isStreaming.value = false - _surfaceStatuses.value = emptyMap() - startBooking(tripId, forceRestart = true) - } - - private fun upsertSurface(surfaceId: String, payload: A2uiComponentPayload) { - val exists = messageProcessor.activeSurfaces.value.any { it.id == surfaceId } - if (!exists) { - messageProcessor.processMessage( - A2uiCreateSurfaceMessage( - surfaceId = surfaceId, - catalogId = BOOKING_ASSISTANT_CATALOG_ID, - ) - ) - } - messageProcessor.processMessage( - A2uiUpdateComponentsMessage( - surfaceId = surfaceId, - components = listOf(payload), - ) - ) - val status = (payload.properties["status"] as? String).orEmpty() - _surfaceStatuses.value = _surfaceStatuses.value + ( - surfaceId to SurfaceStatus( - surfaceId = surfaceId, - type = payload.type, - status = status, - ) - ) - } - - fun getCategory(title: String): String { - val event = tripEvents.find { it.title.equals(title, ignoreCase = true) || it.id == title } - return when (event?.type) { - EventType.TRANSPORTATION -> "Flight" - EventType.ACCOMMODATION -> "Hotel" - EventType.ACTIVITY, EventType.CULTURE -> "Activity" - EventType.FOOD_AND_DRINK -> "Dining" - else -> when { - title.contains("Flight", ignoreCase = true) || - title.contains("AMS", ignoreCase = true) || - title.contains("SFO", ignoreCase = true) -> "Flight" - title.contains("Hotel", ignoreCase = true) || - title.contains("Check-in", ignoreCase = true) || - title.contains("Checkout", ignoreCase = true) || - title.contains("Check out", ignoreCase = true) -> "Hotel" - title.contains("Restaurant", ignoreCase = true) || - title.contains("Dining", ignoreCase = true) || - title.contains("Bistro", ignoreCase = true) || - title.contains("Cafe", ignoreCase = true) -> "Dining" - else -> "Activity" + _uiState.value.activeSurfaces.forEach { surface -> + runCatching { + val deleteJson = + """{"version":"v0.9","deleteSurface":{"surfaceId":${JSONObject.quote(surface.id)}}}""" + messageProcessor.processInput(a2uiParser, deleteJson) } } + _uiState.update { it.copy(isStreaming = false, activeSurfaces = emptyList()) } + initializeChat(tripId, forceReset = true) } - private suspend fun connectToStream(sId: String) { + private suspend fun connectToStream( + sId: String, + command: String = "start", + prompt: String = "", + intent: String = "", + targetTitle: String = "", + ) { val itineraryJson = JSONArray() - val accommodationEvents = tripEvents.filter { it.type == EventType.ACCOMMODATION } - val checkins = accommodationEvents.filter { - it.title.contains("Check-in", ignoreCase = true) || it.title.contains("Check in", ignoreCase = true) - } - val checkouts = accommodationEvents.filter { - it.title.contains("Checkout", ignoreCase = true) || - it.title.contains("Check-out", ignoreCase = true) || - it.title.contains("Check out", ignoreCase = true) - } - val matchedCheckouts = mutableSetOf() - val processedCheckins = mutableSetOf() - - for (checkin in checkins) { - val matchingCheckout = checkouts.find { - it.location.equals(checkin.location, ignoreCase = true) && it.timestamp > checkin.timestamp - } - if (matchingCheckout != null) { - matchedCheckouts.add(matchingCheckout) - processedCheckins.add(checkin) - val eventJson = JSONObject() - eventJson.put("type", "ACCOMMODATION") - val cleanTitle = checkin.location.trim() - eventJson.put("title", if (cleanTitle.isNotEmpty()) cleanTitle else checkin.title) - itineraryJson.put(eventJson) - } - } - for (event in tripEvents) { - if (event.type == EventType.ACCOMMODATION) { - if (checkins.contains(event)) { - if (!processedCheckins.contains(event)) { - val eventJson = JSONObject() - eventJson.put("type", event.type.name) - eventJson.put("title", event.title) - itineraryJson.put(eventJson) - } - } else if (checkouts.contains(event)) { - if (!matchedCheckouts.contains(event)) { - val eventJson = JSONObject() - eventJson.put("type", event.type.name) - eventJson.put("title", event.title) - itineraryJson.put(eventJson) - } - } else { - val eventJson = JSONObject() - eventJson.put("type", event.type.name) - eventJson.put("title", event.title) - itineraryJson.put(eventJson) - } - } else { - val eventJson = JSONObject() - eventJson.put("type", event.type.name) - eventJson.put("title", event.title) - itineraryJson.put(eventJson) - } + val activeTrip = _uiState.value.trip + itineraryJson.put( + BookingEventNormalizer.buildEventJson( + event = event, + eventDao = eventDao, + tripTitle = activeTrip?.title, + tripLocation = activeTrip?.location, + ) + ) } - log("Starting connectToStream with sId: $sId, backendEndpoint: $backendEndpoint") - - if (backendEndpoint == BackendEndpoint.LOCAL) { - streamToken = "local_dev_token" - streamUrl = "http://localhost:8000/run_sse" - } else { - val fbToken = getFirebaseAuthToken() - if (fbToken.isEmpty()) { - log("Failed to obtain Firebase Auth token") - _isStreaming.value = false - return - } - - val gatewayUrl = "$baseUrl/stream?auth_only=true" - val tokenRequest = Request.Builder() - .url(gatewayUrl) - .addHeader("Authorization", "Bearer $fbToken") + val fbToken = getFirebaseAuthToken() + log("connectToStream: fbToken length=${fbToken.length}") + val tokenUrl = "$DEFAULT_BACKEND_URL/stream?auth_only=true" + val tokenRequest = + Request.Builder() + .url(tokenUrl) + .apply { + if (fbToken.isNotEmpty()) addHeader("Authorization", "Bearer $fbToken") + } .get() .build() - client.newCall(tokenRequest).execute().use { tokenResponse -> - if (!tokenResponse.isSuccessful) { - log("Failed to get stream token from gateway: ${tokenResponse.code}") - _isStreaming.value = false - return - } - val responseBody = tokenResponse.body?.string().orEmpty() - val jsonResponse = JSONObject(responseBody) - streamToken = jsonResponse.optString("token") - streamUrl = jsonResponse.optString("url") + client.newCall(tokenRequest).execute().use { tokenResponse -> + log("connectToStream: tokenResponse code=${tokenResponse.code}") + if (!tokenResponse.isSuccessful) { + log("Failed to get stream token: ${tokenResponse.code}") + _uiState.update { it.copy(isStreaming = false) } + return } + val responseBody = tokenResponse.body?.string().orEmpty() + val jsonResponse = JSONObject(responseBody) + streamToken = jsonResponse.optString("token") + streamUrl = jsonResponse.optString("url") + log("connectToStream: streamToken=${streamToken?.take(10)}..., streamUrl=$streamUrl") } val payload = JSONObject() @@ -377,410 +344,240 @@ class BookingAssistantViewModel @Inject constructor( payload.put("streaming", true) val messageJson = JSONObject() - messageJson.put("command", "start") + messageJson.put("command", command) + if (prompt.isNotEmpty()) messageJson.put("prompt", prompt) + if (intent.isNotEmpty()) messageJson.put("intent", intent) + if (targetTitle.isNotEmpty()) messageJson.put("targetTitle", targetTitle) messageJson.put("itinerary", itineraryJson) - val partObj = JSONObject() - partObj.put("text", messageJson.toString()) - val partsArr = JSONArray() - partsArr.put(partObj) + val partsArr = JSONArray().apply { put(JSONObject().apply { put("text", messageJson.toString()) }) } + payload.put("new_message", JSONObject().apply { put("parts", partsArr) }) - val newMessageObj = JSONObject() - newMessageObj.put("parts", partsArr) - payload.put("new_message", newMessageObj) + val st = streamToken ?: return + val su = streamUrl ?: return - val st = streamToken - val su = streamUrl - if (st.isNullOrEmpty() || su.isNullOrEmpty()) { - log("Missing stream credentials") - _isStreaming.value = false - return - } - - log("Connecting to SSE stream at $su...") - val streamRequest = Request.Builder() - .url(su) - .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Bearer $st") - .post(payload.toString().toRequestBody("application/json".toMediaType())) - .build() - - client.newCall(streamRequest).execute().use { streamResponse -> - if (!streamResponse.isSuccessful) { - log("Stream connection failed with code: ${streamResponse.code}") - _isStreaming.value = false - return - } - - log("Stream connected successfully! (code: ${streamResponse.code})") - resetStreamIdleTimer(3000L) - - val source = streamResponse.body?.source() ?: return - val reader = BufferedReader(source.inputStream().reader()) - var line: String? + val sseRequest = + Request.Builder() + .url(su) + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Bearer $st") + .post(payload.toString().toRequestBody("application/json".toMediaType())) + .build() - try { - while (reader.readLine().also { line = it } != null) { - val currentLine = line ?: continue - if (currentLine.startsWith("data: ")) { - val jsonStr = currentLine.substring(6) - _isStreaming.value = true - resetStreamIdleTimer(2000L) - parseAndApplyEvent(jsonStr) + activeStreamJob?.cancel() + activeStreamCall?.cancel() + activeStreamJob = + viewModelScope.launch(Dispatchers.IO) { + val call = client.newCall(sseRequest) + activeStreamCall = call + try { + log("Opening SSE connection to $su") + call.execute().use { response -> + log("SSE connection established: code=${response.code}") + if (!response.isSuccessful) { + log("SSE error response: ${response.code}") + _uiState.update { it.copy(isStreaming = false) } + return@use + } + val reader = BufferedReader(response.body!!.charStream()) + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line != null && line.startsWith("data: ")) { + val data = line.removePrefix("data: ").trim() + if (data.isNotEmpty() && data != "[DONE]") { + processSseChunk(data) + } + } + } + } + } catch (e: Exception) { + log("Stream reading error: ${e.message}") + } finally { + if (activeStreamCall === call) { + activeStreamCall = null } + log("SSE stream closed") + _uiState.update { it.copy(isStreaming = false) } } - } catch (e: Exception) { - log("Stream reading interrupted: ${e.message}") - } finally { - streamIdleJob?.cancel() - _isStreaming.value = false } - } } - private fun parseAndApplyEvent(jsonStr: String) { - try { - val json = JSONObject(jsonStr) + private fun processSseChunk(chunk: String) { + log("processSseChunk: received ${chunk.take(120)}...") + resetStreamIdleTimer() + runCatching { + val json = JSONObject(chunk) val author = json.optString("author") - if (author.isEmpty()) return - - val category = getCategory(author) - val actionsJson = json.optJSONObject("actions") - val endOfAgent = actionsJson?.optBoolean("endOfAgent", false) ?: false - val contentJson = json.optJSONObject("content") - val partsArr = contentJson?.optJSONArray("parts") - - var textContent = "" - var uiType: String? = null - var uiOptions = emptyList() - var uiMessage: String? = null + val contentObj = json.optJSONObject("content") + val partsArr = contentObj?.optJSONArray("parts") if (partsArr != null) { for (i in 0 until partsArr.length()) { val part = partsArr.getJSONObject(i) - if (part.has("text")) { - val txt = part.getString("text") - if (txt.contains("\"ui\":")) { - try { - val uiJson = JSONObject(txt) - uiType = uiJson.optString("ui") - uiMessage = uiJson.optString("message") - val opts = uiJson.optJSONArray("options") - if (opts != null) { - uiOptions = List(opts.length()) { opts.getString(it) } + val text = part.optString("text", "").trim() + if (text.isEmpty()) continue + + if (author == "__assistant__" || text.contains("\"chatMessage\"")) { + runCatching { + val obj = JSONObject(text) + val chatMsg = obj.optString("chatMessage", "") + val sId = obj.optString("surfaceId", "").ifEmpty { null } + if (chatMsg.isNotEmpty()) { + val msgId = + "assistant_${System.currentTimeMillis()}_${_uiState.value.chatMessages.size}" + _uiState.update { state -> + state.copy( + chatMessages = + state.chatMessages + + BookingChatMessage( + id = msgId, + isUser = false, + text = chatMsg, + surfaceId = sId, + ) + ) } - } catch (e: Exception) { - textContent += txt } - } else { - textContent += txt + }.onFailure { + val msgId = + "assistant_${System.currentTimeMillis()}_${_uiState.value.chatMessages.size}" + _uiState.update { state -> + state.copy( + chatMessages = + state.chatMessages + + BookingChatMessage(id = msgId, isUser = false, text = text) + ) + } } } - } - } - - when { - uiType == "time_selection" -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "InteractiveOptionPicker", - properties = mapOf( - "title" to author, - "category" to category, - "status" to "CONFIRMATION REQUIRED", - "prompt" to (uiMessage ?: "Flight Departure"), - "options" to uiOptions, - "selectedOption" to uiOptions.firstOrNull(), - "confirmBtnText" to "Confirm Selection", - "action" to mapOf( - "event" to mapOf( - "name" to "confirm_option", - "context" to mapOf( - "agentId" to author, - "selectionType" to "time", - ), - ) - ), - ), - ), - ) - } - - uiType == "seat_map" || uiType == "seat_selection" -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "SeatSelectionPicker", - properties = mapOf( - "title" to author, - "category" to category, - "status" to "ACTION REQUIRED", - "prompt" to (uiMessage ?: "Seat Selection"), - "options" to uiOptions.ifEmpty { listOf("1A", "1B", "2A", "2B") }, - "selectedSeat" to uiOptions.firstOrNull(), - "confirmBtnText" to "Confirm Selection", - "action" to mapOf( - "event" to mapOf( - "name" to "confirm_seat", - "context" to mapOf( - "agentId" to author, - "selectionType" to "seat", - ), - ) - ), - ), - ), - ) - } - - uiType == "ticket_selection" -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "InteractiveOptionPicker", - properties = mapOf( - "title" to author, - "category" to category, - "status" to "CONFIRMATION REQUIRED", - "prompt" to (uiMessage ?: "Select party size:"), - "options" to uiOptions.ifEmpty { listOf("1", "2", "3", "4+") }, - "selectedOption" to uiOptions.firstOrNull(), - "confirmBtnText" to "Confirm Selection", - "action" to mapOf( - "event" to mapOf( - "name" to "confirm_option", - "context" to mapOf( - "agentId" to author, - "selectionType" to "tickets", - ), - ) - ), - ), - ), - ) - } - - textContent.contains("Please confirm", ignoreCase = true) || - textContent.contains("confirm reservation", ignoreCase = true) || - textContent.contains("confirm tickets", ignoreCase = true) -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "BookingConfirmation", - properties = mapOf( - "category" to category, - "title" to author, - "status" to "CONFIRMATION REQUIRED", - "prompt" to textContent.trim(), - "confirmBtnText" to "Confirm Reservation", - "action" to mapOf( - "event" to mapOf( - "name" to "confirm_booking", - "context" to mapOf( - "agentId" to author, - "selectionType" to "confirm", - ), - ) - ), - ), - ), - ) - } - endOfAgent || - textContent.contains("confirmed", ignoreCase = true) || - textContent.contains("secured", ignoreCase = true) -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "BookingStatus", - properties = mapOf( - "category" to category, - "title" to author, - "description" to textContent.ifEmpty { "Booking confirmed." }, - "status" to "Confirmed", - ), - ), - ) + val directives = extractA2uiDirectives(text) + for (directive in directives) { + messageProcessor.processInput(a2uiParser, directive) + } } - - textContent.isNotEmpty() -> { - upsertSurface( - surfaceId = author, - payload = A2uiComponentPayload( - id = "root", - type = "BookingStatus", - properties = mapOf( - "category" to category, - "title" to author, - "description" to textContent, - "status" to "Processing", - ), - ), - ) + } else { + if (json.has("content")) { + val text = json.optString("content") + if (text.isNotBlank()) { + val msgId = json.optString("id", System.currentTimeMillis().toString()) + _uiState.update { state -> + val existing = state.chatMessages.find { it.id == msgId } + val updatedMessages = + if (existing != null) { + state.chatMessages.map { if (it.id == msgId) it.copy(text = it.text + text) else it } + } else { + state.chatMessages + BookingChatMessage(id = msgId, isUser = false, text = text) + } + state.copy(chatMessages = updatedMessages) + } + } } + messageProcessor.processInput(a2uiParser, chunk) } - } catch (e: Exception) { - log("Error parsing SSE event: ${e.message}") } } private fun handleClientEvent(event: A2uiClientEventMessage) { val context = event.context val agentId = (context["agentId"] as? String) ?: event.surfaceId - val selectionType = (context["selectionType"] as? String) ?: (context["type"] as? String).orEmpty() - val selectedOption = context["selectedOption"] as? String - val selectedSeat = context["selectedSeat"] as? String val sId = sessionId ?: return - log("User interaction for $agentId ($selectionType): option=$selectedOption, seat=$selectedSeat") - - _isStreaming.value = true + _uiState.update { it.copy(isStreaming = true) } resetStreamIdleTimer(5000L) - val category = getCategory(agentId) - upsertSurface( - surfaceId = agentId, - payload = A2uiComponentPayload( - id = "root", - type = "BookingStatus", - properties = mapOf( - "category" to category, - "title" to agentId, - "description" to when (selectionType) { - "time" -> "Selected $selectedOption. Confirming with provider..." - "seat" -> "Selected seat $selectedSeat. Confirming assignment..." - "tickets" -> "Selected party size $selectedOption. Securing reservation..." - else -> "Processing confirmation..." - }, - "status" to "Processing", - ), - ), - ) - viewModelScope.launch(Dispatchers.IO) { try { val json = JSONObject() - when (selectionType) { - "time" -> json.put("time", selectedOption) - "seat" -> json.put("seat", selectedSeat ?: selectedOption) - "tickets" -> { - json.put("tickets", selectedOption) - json.put("people", selectedOption) - } - "confirm", "confirm_booking" -> json.put("confirmed", true) - else -> { - if (selectedOption != null) json.put("time", selectedOption) - else if (selectedSeat != null) json.put("seat", selectedSeat) - else json.put("confirmed", true) - } + json.put("agent_id", agentId) + event.context.forEach { (k, v) -> json.put(k, v) } + val seatVal = event.context["selectedSeat"] ?: event.context["seat"] + if (seatVal != null) { + json.put("seat", seatVal) + } + event.clientDataModel?.toPayloadMap()?.let { dm -> + json.put("clientDataModel", JSONObject(dm)) } val token = getFirebaseAuthToken() val encodedSessionId = URLEncoder.encode(sId, "UTF-8") val encodedAgentId = URLEncoder.encode(agentId, "UTF-8") - val request = Request.Builder() - .url("$baseUrl/respond?session_id=$encodedSessionId&agent_id=$encodedAgentId") - .addHeader("Authorization", "Bearer $token") - .post(json.toString().toRequestBody("application/json".toMediaType())) - .build() + val request = + Request.Builder() + .url("$DEFAULT_BACKEND_URL/respond?session_id=$encodedSessionId&agent_id=$encodedAgentId") + .apply { + if (token.isNotEmpty()) addHeader("Authorization", "Bearer $token") + } + .post(json.toString().toRequestBody("application/json".toMediaType())) + .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) { - log("Error sending response: ${response.code}") - } else { - log("Response successfully delivered to server for $agentId") + log("Error sending respond POST: ${response.code}") } } } catch (e: Exception) { - log("Error sending response to server: ${e.message}") + log("Error sending response: ${e.message}") } } } - private suspend fun getFirebaseAuthToken(): String { - log("getFirebaseAuthToken: getting FirebaseAuth instance") - val auth = try { - FirebaseAuth.getInstance() - } catch (e: Exception) { - log("FirebaseAuth.getInstance failed: ${e.message}") - Log.e("BookingAssistant", "FirebaseAuth.getInstance failed", e) - return "" - } - var user = auth.currentUser - log("getFirebaseAuthToken: current user is ${user?.uid ?: "null"}") - if (user == null) { - log("getFirebaseAuthToken: signing in anonymously...") - user = try { - auth.signInAnonymously().await().user - } catch (e: Exception) { - log("Firebase signInAnonymously failed: ${e.message}") - Log.e("BookingAssistant", "Firebase signInAnonymously failed", e) - null + private fun resetStreamIdleTimer(timeoutMs: Long = 8000L) { + streamIdleJob?.cancel() + streamIdleJob = + viewModelScope.launch { + delay(timeoutMs) + _uiState.update { it.copy(isStreaming = false) } } - log("getFirebaseAuthToken: signed in as ${user?.uid ?: "null"}") - } - return try { - val token = user?.getIdToken(false)?.await()?.token.orEmpty() - log("getFirebaseAuthToken: got token length=${token.length}") - token - } catch (e: Exception) { - log("Firebase getIdToken failed: ${e.message}") - Log.e("BookingAssistant", "Firebase getIdToken failed", e) - "" - } } - fun getCategorySubtitle(surfaces: List): String { - val statuses = surfaces.map { _surfaceStatuses.value[it.id] } - val hasActionRequired = statuses.any { statusInfo -> - statusInfo != null && ( - statusInfo.type in listOf("InteractiveOptionPicker", "SeatSelectionPicker", "BookingConfirmation") || - statusInfo.status.contains("REQUIRED", ignoreCase = true) || - statusInfo.status.contains("ACTION", ignoreCase = true) - ) - } - if (hasActionRequired) { - return "Action required" - } - - val allConfirmed = statuses.isNotEmpty() && statuses.all { statusInfo -> - statusInfo != null && ( - statusInfo.status.equals("Confirmed", ignoreCase = true) || - statusInfo.status.equals("Complete", ignoreCase = true) || - statusInfo.status.equals("Completed", ignoreCase = true) - ) - } - if (allConfirmed) { - return if (surfaces.size > 1) "All confirmed" else "Confirmed" - } - - val anyProcessing = statuses.any { statusInfo -> - statusInfo != null && statusInfo.status.equals("Processing", ignoreCase = true) - } - if (anyProcessing) { - return "Processing..." - } - - val anyConfirmed = statuses.any { statusInfo -> - statusInfo != null && statusInfo.status.equals("Confirmed", ignoreCase = true) - } - if (anyConfirmed) { - val confirmedCount = statuses.count { it?.status.equals("Confirmed", ignoreCase = true) } - return "$confirmedCount of ${surfaces.size} confirmed" - } + override fun onCleared() { + super.onCleared() + streamIdleJob?.cancel() + activeStreamJob?.cancel() + activeStreamCall?.cancel() + } - return "Queued" + private suspend fun getFirebaseAuthToken(): String { + return runCatching { + var user = FirebaseAuth.getInstance().currentUser + if (user == null) { + log("FirebaseAuth: currentUser is null, performing anonymous sign-in...") + FirebaseAuth.getInstance().signInAnonymously().await() + user = FirebaseAuth.getInstance().currentUser + } + val token = user?.getIdToken(false)?.await()?.token.orEmpty() + log("FirebaseAuth: retrieved token (length ${token.length})") + token + } + .onFailure { + log("FirebaseAuth: failed to get token: ${it.message}") + } + .getOrDefault("") } private fun log(message: String) { - Log.e("BookingAssistant", message) - _logs.value = (_logs.value + message).takeLast(100) + Log.i("BookingAssistantVM", message) + } + + companion object { + internal fun extractA2uiDirectives(text: String): List { + val trimmed = text.trim() + if (!trimmed.contains("\"createSurface\"") && + !trimmed.contains("\"updateComponents\"") && + !trimmed.contains("\"updateDataModel\"") && + !trimmed.contains("\"deleteSurface\"") + ) { + return emptyList() + } + return if (trimmed.startsWith("[")) { + runCatching { + val array = JSONArray(trimmed) + (0 until array.length()).map { array.getJSONObject(it).toString() } + }.getOrDefault(emptyList()) + } else { + listOf(trimmed) + } + } } } diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt index 8476b62c..07bcb914 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt @@ -16,775 +16,62 @@ package com.example.jetpacker.feature.booking_assistant -import androidx.a2ui.compose.runtime.A2uiComponentProperties -import androidx.a2ui.compose.runtime.A2uiComponentScope -import androidx.a2ui.compose.runtime.A2uiProperty import androidx.a2ui.compose.ui.A2uiCatalog -import androidx.a2ui.compose.ui.A2uiComponent -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.ConfirmationNumber -import androidx.compose.material.icons.rounded.Flight -import androidx.compose.material.icons.rounded.Hotel -import androidx.compose.material.icons.rounded.Restaurant -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import com.example.jetpacker.feature.booking_assistant.catalog.AircraftCabinSeatMapComponent +import com.example.jetpacker.feature.booking_assistant.catalog.BookingConfirmationComponent +import com.example.jetpacker.feature.booking_assistant.catalog.BookingStatusComponent +import com.example.jetpacker.feature.booking_assistant.catalog.DigitalHotelKeyPassComponent +import com.example.jetpacker.feature.booking_assistant.catalog.FlightAddonsCardComponent +import com.example.jetpacker.feature.booking_assistant.catalog.FlightConfiguratorShellComponent +import com.example.jetpacker.feature.booking_assistant.catalog.FlightOptionSelectorComponent +import com.example.jetpacker.feature.booking_assistant.catalog.HotelConfiguratorShellComponent +import com.example.jetpacker.feature.booking_assistant.catalog.HotelLiveSummaryFooterComponent +import com.example.jetpacker.feature.booking_assistant.catalog.HotelRoomSelectorComponent +import com.example.jetpacker.feature.booking_assistant.catalog.HotelStayCustomizerComponent +import com.example.jetpacker.feature.booking_assistant.catalog.InteractiveOptionPickerComponent +import com.example.jetpacker.feature.booking_assistant.catalog.LiveFareSummaryFooterComponent +import com.example.jetpacker.feature.booking_assistant.catalog.PerforatedBoardingPassComponent +import com.example.jetpacker.feature.booking_assistant.catalog.SeatSelectionPickerComponent const val BOOKING_ASSISTANT_CATALOG_ID: String = "https://example.com/catalogs/booking_assistant/v1/catalog.json" /** - * Returns the custom A2UI catalog for Jetpacker's Booking Assistant. + * Returns the hybrid A2UI catalog for JetPacker's Booking Assistant, combining standard Material 3 + * A2UI components ([MaterialA2uiBasicCatalogV1Defaults]) with custom domain-specific travel + * components. */ fun bookingAssistantCatalog(): A2uiCatalog { return A2uiCatalog( catalogId = BOOKING_ASSISTANT_CATALOG_ID, - components = listOf( - InteractiveOptionPickerComponent(), - SeatSelectionPickerComponent(), - BookingStatusComponent(), - BookingConfirmationComponent(), - ), + components = + listOf( + // Standard Material 3 A2UI basic catalog components (Hybrid Catalog showcase) + MaterialA2uiBasicCatalogV1Defaults.card, + MaterialA2uiBasicCatalogV1Defaults.column, + MaterialA2uiBasicCatalogV1Defaults.row, + MaterialA2uiBasicCatalogV1Defaults.text, + MaterialA2uiBasicCatalogV1Defaults.checkBox, + MaterialA2uiBasicCatalogV1Defaults.divider, + // Hero Agentic Flight Configurator custom components + FlightConfiguratorShellComponent(), + FlightOptionSelectorComponent(), + AircraftCabinSeatMapComponent(), + FlightAddonsCardComponent(), + LiveFareSummaryFooterComponent(), + PerforatedBoardingPassComponent(), + // Multi-Step Boutique Hotel Configurator custom components + HotelConfiguratorShellComponent(), + HotelRoomSelectorComponent(), + HotelStayCustomizerComponent(), + HotelLiveSummaryFooterComponent(), + DigitalHotelKeyPassComponent(), + // Supporting itinerary components (Hotels, Activities, Dining, Fallback) + InteractiveOptionPickerComponent(), + SeatSelectionPickerComponent(), + BookingStatusComponent(), + BookingConfirmationComponent(), + ), ) } - -/** - * An interactive option picker that allows selecting a single option from a list and confirming. - */ -class InteractiveOptionPickerComponent : A2uiComponent { - private val titleProp = - A2uiProperty.dynamicString( - key = "title", - required = false, - description = "The title of the booking item.", - ) - - private val categoryProp = - A2uiProperty.dynamicString( - key = "category", - required = false, - description = "The booking category.", - ) - - private val statusProp = - A2uiProperty.dynamicString( - key = "status", - required = false, - description = "Status chip label (e.g. CONFIRMATION REQUIRED).", - ) - - private val promptProp = - A2uiProperty.dynamicString( - key = "prompt", - required = true, - description = "The prompt or instruction to display to the user.", - ) - - private val optionsProp = - A2uiProperty.dynamicStringList( - key = "options", - required = true, - description = "The list of selectable options.", - ) - - private val selectedOptionProp = - A2uiProperty.dynamicString( - key = "selectedOption", - required = false, - description = "The currently selected option string.", - ) - - private val confirmBtnTextProp = - A2uiProperty.dynamicString( - key = "confirmBtnText", - required = false, - description = "The label for the confirmation button.", - ) - - private val actionProp = - A2uiProperty.action( - key = "action", - required = false, - description = "Custom action payload dispatched when the confirm button is clicked.", - ) - - override val name: String = "InteractiveOptionPicker" - override val description: String = - "An interactive option picker allowing users to select an option and confirm." - override val properties: List> = - listOf(titleProp, categoryProp, statusProp, promptProp, optionsProp, selectedOptionProp, confirmBtnTextProp, actionProp) - - @Composable - override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { - return properties.bind(promptProp) != null && properties.bind(optionsProp) != null - } - - @Composable - override fun A2uiComponentScope.Content( - properties: A2uiComponentProperties, - modifier: Modifier, - ) { - val title = properties.bind(titleProp) - val category = properties.bind(categoryProp) ?: "Flight" - val status = properties.bind(statusProp) ?: "CONFIRMATION REQUIRED" - val prompt = properties.bind(promptProp) ?: "Flight Departure" - val options = properties.bind(optionsProp) ?: emptyList() - val initialSelected = properties.bind(selectedOptionProp) - val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" - val action = properties[actionProp] - - var selected by rememberSaveable(initialSelected) { - mutableStateOf(initialSelected ?: options.firstOrNull()) - } - - val categoryIcon = when { - category.contains("Hotel", ignoreCase = true) -> Icons.Rounded.Hotel - category.contains("Activity", ignoreCase = true) -> Icons.Rounded.ConfirmationNumber - category.contains("Dining", ignoreCase = true) -> Icons.Rounded.Restaurant - else -> Icons.Rounded.Flight - } - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (!title.isNullOrEmpty()) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 17.sp, - ), - color = Color(0xFF1E293B), - ) - } - - Box( - modifier = Modifier - .background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) { - Text( - text = status.uppercase(), - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 0.5.sp, - fontSize = 11.sp, - ), - color = Color(0xFF8A1F11), - ) - } - - Text( - text = prompt, - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - color = Color(0xFF1E293B), - ) - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - options.forEach { option -> - val isSelected = option == selected - Row( - modifier = - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Color.White) - .border( - BorderStroke( - if (isSelected) 1.5.dp else 1.dp, - if (isSelected) Color(0xFF94A3B8) else Color(0xFFD1D5DB), - ), - RoundedCornerShape(16.dp), - ) - .clickable { selected = option } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = categoryIcon, - contentDescription = null, - tint = Color(0xFF374151), - modifier = Modifier.size(20.dp), - ) - Text( - text = option, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - fontSize = 15.sp, - ), - color = Color(0xFF1F2937), - modifier = Modifier.weight(1f), - ) - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Button( - onClick = { - val context = mutableMapOf( - "type" to "confirm_option", - ) - if (selected != null) { - context["selectedOption"] = selected!! - } - val eventPayload = if (action != null && action.containsKey("event")) { - val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() - val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() - val mergedContext = originalContext.toMutableMap() - mergedContext.putAll(context) - mapOf( - "event" to mapOf( - "name" to (originalEvent["name"] as? String ?: "confirm_option"), - "context" to mergedContext, - ) - ) - } else { - mapOf( - "event" to mapOf( - "name" to "confirm_option", - "context" to context, - ) - ) - } - dispatchAction(eventPayload) - }, - enabled = selected != null, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFE5E2DA), - contentColor = Color(0xFF78716C), - disabledContainerColor = Color(0xFFE5E2DA).copy(alpha = 0.5f), - disabledContentColor = Color(0xFF78716C).copy(alpha = 0.5f), - ), - ) { - Text( - text = confirmBtnText, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - ) - } - } - } -} - -/** - * A seat selection picker for flights or travel segments. - */ -class SeatSelectionPickerComponent : A2uiComponent { - private val titleProp = - A2uiProperty.dynamicString( - key = "title", - required = false, - description = "The title of the booking item.", - ) - - private val categoryProp = - A2uiProperty.dynamicString( - key = "category", - required = false, - description = "Booking category.", - ) - - private val statusProp = - A2uiProperty.dynamicString( - key = "status", - required = false, - description = "Status chip label (e.g. ACTION REQUIRED).", - ) - - private val promptProp = - A2uiProperty.dynamicString( - key = "prompt", - required = false, - description = "The prompt for seat selection.", - ) - - private val optionsProp = - A2uiProperty.dynamicStringList( - key = "options", - required = true, - description = "Available seats list.", - ) - - private val selectedSeatProp = - A2uiProperty.dynamicString( - key = "selectedSeat", - required = false, - description = "Currently selected seat.", - ) - - private val confirmBtnTextProp = - A2uiProperty.dynamicString( - key = "confirmBtnText", - required = false, - description = "Button label for confirmation.", - ) - - private val actionProp = - A2uiProperty.action( - key = "action", - required = false, - description = "Action payload dispatched when confirming seat selection.", - ) - - override val name: String = "SeatSelectionPicker" - override val description: String = - "A seat selection picker displaying seat options and a confirmation button." - override val properties: List> = - listOf(titleProp, categoryProp, statusProp, promptProp, optionsProp, selectedSeatProp, confirmBtnTextProp, actionProp) - - @Composable - override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { - return properties.bind(optionsProp) != null - } - - @Composable - override fun A2uiComponentScope.Content( - properties: A2uiComponentProperties, - modifier: Modifier, - ) { - val title = properties.bind(titleProp) - val status = properties.bind(statusProp) ?: "ACTION REQUIRED" - val prompt = properties.bind(promptProp) ?: "Seat Selection" - val options = properties.bind(optionsProp) ?: listOf("1A", "1B", "1C", "2A", "2B", "2C") - val initialSeat = properties.bind(selectedSeatProp) - val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" - val action = properties[actionProp] - - var selectedSeat by rememberSaveable(initialSeat) { mutableStateOf(initialSeat) } - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (!title.isNullOrEmpty()) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 17.sp, - ), - color = Color(0xFF1E293B), - ) - } - - Box( - modifier = Modifier - .background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) { - Text( - text = status.uppercase(), - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 0.5.sp, - fontSize = 11.sp, - ), - color = Color(0xFF8A1F11), - ) - } - - Text( - text = prompt, - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - color = Color(0xFF1E293B), - ) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.chunked(4).forEach { rowSeats -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - rowSeats.forEach { seat -> - val isSelected = seat == selectedSeat - OutlinedButton( - onClick = { selectedSeat = seat }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = - if (isSelected) { - ButtonDefaults.outlinedButtonColors( - containerColor = Color(0xFF1E293B), - contentColor = Color.White, - ) - } else { - ButtonDefaults.outlinedButtonColors( - containerColor = Color.White, - contentColor = Color(0xFF1E293B), - ) - }, - border = - if (isSelected) null - else BorderStroke(1.dp, Color(0xFFD1D5DB)), - ) { - Text( - text = seat, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ), - ) - } - } - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Button( - onClick = { - val context = mutableMapOf( - "type" to "confirm_seat", - ) - if (selectedSeat != null) { - context["selectedSeat"] = selectedSeat!! - } - val eventPayload = if (action != null && action.containsKey("event")) { - val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() - val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() - val mergedContext = originalContext.toMutableMap() - mergedContext.putAll(context) - mapOf( - "event" to mapOf( - "name" to (originalEvent["name"] as? String ?: "confirm_seat"), - "context" to mergedContext, - ) - ) - } else { - mapOf( - "event" to mapOf( - "name" to "confirm_seat", - "context" to context, - ) - ) - } - dispatchAction(eventPayload) - }, - enabled = selectedSeat != null, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFE5E2DA), - contentColor = Color(0xFF78716C), - disabledContainerColor = Color(0xFFE5E2DA).copy(alpha = 0.5f), - disabledContentColor = Color(0xFF78716C).copy(alpha = 0.5f), - ), - ) { - Text( - text = confirmBtnText, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - ) - } - } - } -} - -/** - * Displays booking confirmation status, title, details, and status badge. - */ -class BookingStatusComponent : A2uiComponent { - private val categoryProp = - A2uiProperty.dynamicString( - key = "category", - required = false, - description = "Booking category: Flight, Hotel, Activity, Dining.", - ) - - private val titleProp = - A2uiProperty.dynamicString( - key = "title", - required = true, - description = "Title of the booking item.", - ) - - private val descriptionProp = - A2uiProperty.dynamicString( - key = "description", - required = false, - description = "Booking description or update details.", - ) - - private val statusProp = - A2uiProperty.dynamicString( - key = "status", - required = true, - description = "Status string: Confirmed, Action Required, Processing, Complete, Queued.", - ) - - override val name: String = "BookingStatus" - override val description: String = - "Displays the booking status with icon, badge, title, and descriptive message." - override val properties: List> = - listOf(categoryProp, titleProp, descriptionProp, statusProp) - - @Composable - override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { - return properties.bind(titleProp) != null && properties.bind(statusProp) != null - } - - @Composable - override fun A2uiComponentScope.Content( - properties: A2uiComponentProperties, - modifier: Modifier, - ) { - val category = properties.bind(categoryProp) ?: "Booking" - val title = properties.bind(titleProp) ?: "Travel Booking" - val description = properties.bind(descriptionProp) - val status = properties.bind(statusProp) ?: "Processing" - - val (bg, txt) = - when (status.uppercase()) { - "COMPLETE", - "CONFIRMED" -> Color(0xFFD1FAE5) to Color(0xFF065F46) - "CONFIRMATION REQUIRED", - "ACTION REQUIRED" -> Color(0xFFFDE8E8) to Color(0xFF8A1F11) - "RUNNING", - "PROCESSING...", - "PROCESSING" -> Color(0xFFE0F2FE) to Color(0xFF0369A1) - else -> Color(0xFFF1F5F9) to Color(0xFF475569) - } - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 17.sp, - ), - color = Color(0xFF1E293B), - ) - - Box( - modifier = - Modifier.background(bg, RoundedCornerShape(8.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp) - ) { - Text( - text = status.uppercase(), - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 0.5.sp, - fontSize = 11.sp, - ), - color = txt, - ) - } - - if (!description.isNullOrEmpty()) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - lineHeight = 20.sp, - ), - color = Color(0xFF475569), - ) - } - } - } -} - -/** - * A booking confirmation card that allows confirming a reservation or ticket. - */ -class BookingConfirmationComponent : A2uiComponent { - private val categoryProp = - A2uiProperty.dynamicString( - key = "category", - required = false, - description = "Booking category: Flight, Hotel, Activity, Dining.", - ) - - private val titleProp = - A2uiProperty.dynamicString( - key = "title", - required = true, - description = "Title of the booking item.", - ) - - private val statusProp = - A2uiProperty.dynamicString( - key = "status", - required = false, - description = "Status badge text.", - ) - - private val promptProp = - A2uiProperty.dynamicString( - key = "prompt", - required = true, - description = "Justification or details explaining the booking selection.", - ) - - private val confirmBtnTextProp = - A2uiProperty.dynamicString( - key = "confirmBtnText", - required = false, - description = "Label for the confirmation button.", - ) - - private val actionProp = - A2uiProperty.action( - key = "action", - required = false, - description = "Action payload dispatched when confirmed.", - ) - - override val name: String = "BookingConfirmation" - override val description: String = - "A confirmation card with justification details and a confirm button." - override val properties: List> = - listOf(categoryProp, titleProp, statusProp, promptProp, confirmBtnTextProp, actionProp) - - @Composable - override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { - return properties.bind(titleProp) != null && properties.bind(promptProp) != null - } - - @Composable - override fun A2uiComponentScope.Content( - properties: A2uiComponentProperties, - modifier: Modifier, - ) { - val category = properties.bind(categoryProp) ?: "Booking" - val title = properties.bind(titleProp) ?: "Reservation" - val status = properties.bind(statusProp) ?: "CONFIRMATION REQUIRED" - val prompt = properties.bind(promptProp) ?: "Please confirm reservation." - val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Reservation" - val action = properties[actionProp] - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 17.sp, - ), - color = Color(0xFF1E293B), - ) - - Box( - modifier = - Modifier.background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp) - ) { - Text( - text = status.uppercase(), - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 0.5.sp, - fontSize = 11.sp, - ), - color = Color(0xFF8A1F11), - ) - } - - Text( - text = prompt, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - lineHeight = 20.sp, - ), - color = Color(0xFF334155), - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Button( - onClick = { - val context = mutableMapOf( - "type" to "confirm_booking", - ) - val eventPayload = if (action != null && action.containsKey("event")) { - val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() - val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() - val mergedContext = originalContext.toMutableMap() - mergedContext.putAll(context) - mapOf( - "event" to mapOf( - "name" to (originalEvent["name"] as? String ?: "confirm_booking"), - "context" to mergedContext, - ) - ) - } else { - mapOf( - "event" to mapOf( - "name" to "confirm_booking", - "context" to context, - ) - ) - } - dispatchAction(eventPayload) - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFE5E2DA), - contentColor = Color(0xFF78716C), - ), - ) { - Text( - text = confirmBtnText, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 15.sp, - ), - ) - } - } - } -} - diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/AircraftCabinSeatMapComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/AircraftCabinSeatMapComponent.kt new file mode 100644 index 00000000..70745730 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/AircraftCabinSeatMapComponent.kt @@ -0,0 +1,677 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 2 & Act 3 Interactive Aircraft Fuselage & Cabin Seat Map: + * - Draws a curved aircraft fuselage nose cone, cockpit glazing, cabin windows, and swept wings + * using Jetpack Compose `Canvas`. + * - Supports two-way data binding (`bindUpdater`) to `/booking/seat` and `/booking/seatUpcharge`. + * - Detects surgical server-pushed `updateDataModel` changes to `/cabin/occupiedSeats` and pulses + * newly taken seats in Tangelo Coral without rebuilding the component hierarchy. + */ +class AircraftCabinSeatMapComponent : A2uiComponent { + private val aircraftProp = + A2uiProperty.dynamicString( + key = "aircraft", + required = false, + description = "Aircraft model and cabin deck title.", + ) + private val occupiedSeatsProp = + A2uiProperty.dynamicStringList( + key = "occupiedSeats", + required = false, + description = "List of occupied seat designators.", + ) + private val liveAlertProp = + A2uiProperty.dynamicString( + key = "liveAlert", + required = false, + description = "Live surgical server update banner text.", + ) + private val selectedSeatProp = + A2uiProperty.dynamicString( + key = "selectedSeat", + required = false, + description = "Two-way bound currently selected seat.", + ) + private val seatUpchargeProp = + A2uiProperty.dynamicNumber( + key = "seatUpcharge", + required = false, + description = "Two-way bound seat tier upgrade fee in USD.", + ) + + override val name: String = "AircraftCabinSeatMap" + override val description: String = + "Interactive aircraft fuselage cabin seat map with live seat availability patching." + override val properties: List> = + listOf(aircraftProp, occupiedSeatsProp, liveAlertProp, selectedSeatProp, seatUpchargeProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean = true + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val aircraft = properties.bind(aircraftProp) ?: "Widebody Front Cabin" + val occupiedSeats = properties.bind(occupiedSeatsProp) ?: listOf("1B", "3B") + val liveAlert = properties.bind(liveAlertProp).orEmpty() + val boundSeat = properties.bind(selectedSeatProp) + val updateSeat = properties.bindUpdater(selectedSeatProp) + val updateUpcharge = properties.bindUpdater(seatUpchargeProp) + + var fallbackSeat by rememberSaveable { mutableStateOf("1A") } + val selectedSeat = boundSeat ?: fallbackSeat + + // Track surgical server additions to occupiedSeats so we can highlight newly booked seats + var initialOccupiedSnapshot by remember { mutableStateOf?>(null) } + var newlyBookedSeats by remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(occupiedSeats) { + val currentSet = occupiedSeats.toSet() + val baseline = initialOccupiedSnapshot + if (baseline == null) { + initialOccupiedSnapshot = currentSet + } else { + val added = currentSet - baseline + if (added.isNotEmpty()) { + newlyBookedSeats = newlyBookedSeats + added + } + } + } + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Section Title & Legend Stacked Cleanly for Mobile Width + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.AirlineSeatReclineExtra, + contentDescription = null, + tint = JetDeepNavy, + modifier = Modifier.size(18.dp), + ) + Text( + text = aircraft.uppercase(), + style = + MaterialTheme.typography.labelMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + letterSpacing = 0.5.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CabinLegendDot(color = JetDeepNavy, label = "Yours") + CabinLegendDot(color = JetRoyalCobalt, label = "Suite +$85") + CabinLegendDot(color = JetMutedSlate.copy(alpha = 0.45f), label = "Taken") + } + } + + // Live Surgical Server Patch Alert Banner + AnimatedVisibility( + visible = liveAlert.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(JetTangeloSoft) + .border(BorderStroke(1.dp, JetTangelo), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.background(JetTangelo, RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "LIVE", + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = SekuyaFontFamily, + fontSize = 9.sp, + ), + color = Color.White, + ) + } + Text( + text = liveAlert, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + color = JetDeepNavy, + ) + } + } + + // Custom Compose Canvas Aircraft Fuselage + Interactive Seat Deck + Box( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .border(BorderStroke(1.dp, Color(0xFFE2E8F0)), RoundedCornerShape(24.dp)) + .padding(vertical = 12.dp, horizontal = 6.dp), + contentAlignment = Alignment.TopCenter, + ) { + // Aircraft Nose Cone, Cockpit Glazing, Fuselage Walls & Swept Wings Backdrop + Canvas(modifier = Modifier.matchParentSize()) { + val w = size.width + val h = size.height + val fuselageLeft = w * 0.03f + val fuselageRight = w * 0.97f + + // Swept wings on left & right sides + val leftWing = + Path().apply { + moveTo(fuselageLeft, h * 0.38f) + lineTo(0f, h * 0.52f) + lineTo(0f, h * 0.68f) + lineTo(fuselageLeft, h * 0.62f) + close() + } + val rightWing = + Path().apply { + moveTo(fuselageRight, h * 0.38f) + lineTo(w, h * 0.52f) + lineTo(w, h * 0.68f) + lineTo(fuselageRight, h * 0.62f) + close() + } + drawPath(leftWing, color = JetSkyCyan.copy(alpha = 0.25f)) + drawPath(rightWing, color = JetSkyCyan.copy(alpha = 0.25f)) + + // Fuselage outer shell + val fuselagePath = + Path().apply { + moveTo(fuselageLeft, h) + lineTo(fuselageLeft, h * 0.18f) + quadraticTo(w * 0.5f, -h * 0.05f, fuselageRight, h * 0.18f) + lineTo(fuselageRight, h) + close() + } + drawPath( + path = fuselagePath, + brush = + Brush.verticalGradient( + colors = listOf(JetSkyCyanSoft.copy(alpha = 0.55f), JetWarmCream.copy(alpha = 0.5f)) + ), + ) + drawPath( + path = fuselagePath, + color = JetDeepNavy.copy(alpha = 0.22f), + style = Stroke(width = 2.dp.toPx()), + ) + + // Cockpit windshield arc + val cockpitPath = + Path().apply { + moveTo(w * 0.36f, h * 0.08f) + quadraticTo(w * 0.5f, h * 0.035f, w * 0.64f, h * 0.08f) + } + drawPath( + path = cockpitPath, + color = JetDeepNavy.copy(alpha = 0.35f), + style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round), + ) + + // Cabin window indicator ticks along fuselage walls + val rowYPositions = listOf(0.32f, 0.50f, 0.71f, 0.88f) + rowYPositions.forEach { frac -> + val y = h * frac + drawRoundRect( + color = JetSkyCyan, + topLeft = Offset(fuselageLeft + 3.dp.toPx(), y - 8.dp.toPx()), + size = Size(4.dp.toPx(), 16.dp.toPx()), + cornerRadius = CornerRadius(4f, 4f), + ) + drawRoundRect( + color = JetSkyCyan, + topLeft = Offset(fuselageRight - 7.dp.toPx(), y - 8.dp.toPx()), + size = Size(4.dp.toPx(), 16.dp.toPx()), + cornerRadius = CornerRadius(4f, 4f), + ) + } + } + + // Interactive Cabin Seat Grid + Column( + modifier = Modifier.fillMaxWidth(0.90f).padding(top = 30.dp, bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Column Headers: A (Win) B (Aisle) | C (Aisle) D (Win) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("A • WIN", style = cabinColumnHeaderStyle(), color = JetSlate) + Text("B", style = cabinColumnHeaderStyle(), color = JetSlate) + Text("AISLE", style = cabinColumnHeaderStyle(), color = JetMutedSlate) + Text("C", style = cabinColumnHeaderStyle(), color = JetSlate) + Text("WIN • D", style = cabinColumnHeaderStyle(), color = JetSlate) + } + + // Rows 1 & 2: Business Class Suites (+$85) + listOf(1, 2).forEach { rowNum -> + CabinSeatRow( + rowNumber = rowNum, + isBusinessSuite = true, + upcharge = 85, + selectedSeat = selectedSeat, + occupiedSeats = occupiedSeats, + newlyBookedSeats = newlyBookedSeats, + onSelectSeat = { seatId, fee -> + fallbackSeat = seatId + updateSeat?.invoke(seatId) + updateUpcharge?.invoke(fee) + }, + ) + } + + // Cabin Class Divider + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f).height(1.dp).background(Color(0xFFCBD5E1))) + Text( + text = "COMFORT+ CABIN (+$35 / INCL)", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 0.5.sp, + ), + color = JetRoyalCobalt, + maxLines = 1, + ) + Box(modifier = Modifier.weight(1f).height(1.dp).background(Color(0xFFCBD5E1))) + } + + // Rows 3 & 4: Comfort+ & Main Cabin (+$35 / $0) + listOf(3, 4).forEach { rowNum -> + val fee = if (rowNum == 3) 35 else 0 + CabinSeatRow( + rowNumber = rowNum, + isBusinessSuite = false, + upcharge = fee, + selectedSeat = selectedSeat, + occupiedSeats = occupiedSeats, + newlyBookedSeats = newlyBookedSeats, + onSelectSeat = { seatId, seatFee -> + fallbackSeat = seatId + updateSeat?.invoke(seatId) + updateUpcharge?.invoke(seatFee) + }, + ) + } + } + } + } + } +} + +@Composable +private fun CabinSeatRow( + rowNumber: Int, + isBusinessSuite: Boolean, + upcharge: Int, + selectedSeat: String, + occupiedSeats: List, + newlyBookedSeats: Set, + onSelectSeat: (String, Int) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("A", "B").forEach { col -> + val seatId = "$rowNumber$col" + CabinSeatTile( + seatId = seatId, + isBusinessSuite = isBusinessSuite, + upcharge = upcharge, + isSelected = selectedSeat.equals(seatId, ignoreCase = true), + isOccupied = + occupiedSeats.any { it.equals(seatId, ignoreCase = true) } && + !selectedSeat.equals(seatId, ignoreCase = true), + isNewlyBooked = newlyBookedSeats.any { it.equals(seatId, ignoreCase = true) }, + onClick = { onSelectSeat(seatId, upcharge) }, + ) + } + } + + // Center Aisle Row Indicator + Box( + modifier = + Modifier.width(22.dp) + .height(22.dp) + .background(JetWarmCream, CircleShape) + .border(BorderStroke(1.dp, Color(0xFFE2E8F0)), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = rowNumber.toString(), + style = + MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold), + color = JetSlate, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("C", "D").forEach { col -> + val seatId = "$rowNumber$col" + CabinSeatTile( + seatId = seatId, + isBusinessSuite = isBusinessSuite, + upcharge = upcharge, + isSelected = selectedSeat.equals(seatId, ignoreCase = true), + isOccupied = + occupiedSeats.any { it.equals(seatId, ignoreCase = true) } && + !selectedSeat.equals(seatId, ignoreCase = true), + isNewlyBooked = newlyBookedSeats.any { it.equals(seatId, ignoreCase = true) }, + onClick = { onSelectSeat(seatId, upcharge) }, + ) + } + } + } +} + +@Composable +private fun CabinSeatTile( + seatId: String, + isBusinessSuite: Boolean, + upcharge: Int, + isSelected: Boolean, + isOccupied: Boolean, + isNewlyBooked: Boolean, + onClick: () -> Unit, +) { + val scale by + animateFloatAsState( + targetValue = + when { + isSelected -> 1.08f + isNewlyBooked -> 1.05f + else -> 1.0f + }, + animationSpec = + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium), + label = "SeatScale", + ) + + val bgColor by + animateColorAsState( + targetValue = + when { + isSelected -> JetDeepNavy + isNewlyBooked -> JetTangeloSoft + isOccupied -> Color(0xFFE2E8F0) + isBusinessSuite -> Color.White + else -> JetWarmCream + }, + label = "SeatBg", + ) + + val borderColor by + animateColorAsState( + targetValue = + when { + isSelected -> JetSkyCyan + isNewlyBooked -> JetTangelo + isOccupied -> Color(0xFFCBD5E1) + isBusinessSuite -> JetRoyalCobalt + else -> Color(0xFF94A3B8) + }, + label = "SeatBorder", + ) + + val tileHeight = if (isBusinessSuite) 48.dp else 44.dp + val shape = + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 8.dp, bottomEnd = 8.dp) + + Box( + modifier = + Modifier.width(44.dp) + .height(tileHeight) + .scale(scale) + .clip(shape) + .background(bgColor) + .border(BorderStroke(if (isSelected || isNewlyBooked) 2.dp else 1.2.dp, borderColor), shape) + .clickable(enabled = !isOccupied, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + // Headrest pillow bar at top of seat + Box( + modifier = + Modifier.align(Alignment.TopCenter) + .padding(top = 3.dp) + .width(26.dp) + .height(4.dp) + .clip(RoundedCornerShape(50)) + .background( + when { + isSelected -> JetSkyCyan + isNewlyBooked -> JetTangelo + isOccupied -> Color(0xFF94A3B8) + isBusinessSuite -> JetRoyalCobalt.copy(alpha = 0.45f) + else -> JetMutedSlate.copy(alpha = 0.45f) + } + ) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + text = if (isOccupied && !isNewlyBooked) "✕" else seatId, + style = + MaterialTheme.typography.labelMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + ), + color = + when { + isSelected -> Color.White + isNewlyBooked -> JetTangelo + isOccupied -> JetMutedSlate + else -> JetDeepNavy + }, + ) + Text( + text = + when { + isSelected -> "YOURS" + isNewlyBooked -> "TAKEN" + isOccupied -> "BOOKED" + upcharge > 0 -> "+$$upcharge" + else -> "INCL" + }, + style = + MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 7.sp), + color = + when { + isSelected -> JetSkyCyan + isNewlyBooked -> JetTangelo + isOccupied -> JetMutedSlate + upcharge > 0 -> JetRoyalCobalt + else -> JetSlate + }, + ) + } + } +} + +@Composable +private fun CabinLegendDot(color: Color, label: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(modifier = Modifier.size(8.dp).background(color, CircleShape)) + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, fontWeight = FontWeight.Normal), + color = JetSlate, + ) + } +} + +@Composable +private fun cabinColumnHeaderStyle() = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.4.sp, + ) + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingConfirmationComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingConfirmationComponent.kt new file mode 100644 index 00000000..09e0895b --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingConfirmationComponent.kt @@ -0,0 +1,261 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** A booking confirmation card that allows confirming a reservation or ticket. */ +class BookingConfirmationComponent : A2uiComponent { + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category: Flight, Hotel, Activity, Dining.", + ) + + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = true, + description = "Title of the booking item.", + ) + + private val statusProp = + A2uiProperty.dynamicString(key = "status", required = false, description = "Status badge text.") + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = true, + description = "Justification or details explaining the booking selection.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Label for the confirmation button.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action payload dispatched when confirmed.", + ) + + override val name: String = "BookingConfirmation" + override val description: String = + "A confirmation card with justification details and a confirm button." + override val properties: List> = + listOf(categoryProp, titleProp, statusProp, promptProp, confirmBtnTextProp, actionProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(titleProp) != null && properties.bind(promptProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val title = properties.bind(titleProp) ?: "Reservation" + val status = properties.bind(statusProp) ?: "CONFIRMATION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Please confirm reservation." + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Reservation" + val action = properties[actionProp] + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(JetWarmCream.copy(alpha = 0.65f)) + .border(BorderStroke(1.dp, Color(0xFFE2E8F0)), RoundedCornerShape(20.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = JetDeepNavy, + modifier = Modifier.weight(1f), + ) + + Box( + modifier = + Modifier.background(JetTangeloSoft, RoundedCornerShape(50)) + .border(BorderStroke(1.dp, JetTangelo.copy(alpha = 0.35f)), RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = status.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 10.sp, + ), + color = JetTangelo, + ) + } + } + + Text( + text = prompt, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 20.sp), + color = JetSlate, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Button( + onClick = { + val context = mutableMapOf("type" to "confirm_booking") + dispatchMergedAction(action, "confirm_booking", context) + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetDeepNavy, contentColor = Color.White), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp), + ) + } + } + } +} + + + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingStatusComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingStatusComponent.kt new file mode 100644 index 00000000..6e6eb45f --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/BookingStatusComponent.kt @@ -0,0 +1,263 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** Displays booking confirmation status, title, details, and status badge. */ +class BookingStatusComponent : A2uiComponent { + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category: Flight, Hotel, Activity, Dining.", + ) + + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = true, + description = "Title of the booking item.", + ) + + private val descriptionProp = + A2uiProperty.dynamicString( + key = "description", + required = false, + description = "Booking description or update details.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = true, + description = "Status string: Confirmed, Action Required, Processing, Complete, Queued.", + ) + + override val name: String = "BookingStatus" + override val description: String = + "Displays the booking status with icon, badge, title, and descriptive message." + override val properties: List> = + listOf(categoryProp, titleProp, descriptionProp, statusProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(titleProp) != null && properties.bind(statusProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val title = properties.bind(titleProp) ?: "Travel Booking" + val description = properties.bind(descriptionProp) + val status = properties.bind(statusProp) ?: "Processing" + + val isConfirmed = + status.equals("Confirmed", ignoreCase = true) || status.equals("Complete", ignoreCase = true) + + val (bg, txt) = + when (status.uppercase()) { + "COMPLETE", + "CONFIRMED" -> JetEmeraldSoft to JetEmerald + "CONFIRMATION REQUIRED", + "ACTION REQUIRED" -> JetTangeloSoft to JetTangelo + "RUNNING", + "PROCESSING...", + "PROCESSING" -> JetSkyCyanSoft to JetRoyalCobalt + else -> Color(0xFFF1F5F9) to JetSlate + } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background( + if (isConfirmed) JetEmeraldSoft.copy(alpha = 0.25f) + else JetWarmCream.copy(alpha = 0.55f) + ) + .border( + BorderStroke( + 1.dp, + if (isConfirmed) JetEmerald.copy(alpha = 0.35f) else Color(0xFFE2E8F0), + ), + RoundedCornerShape(18.dp), + ) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ), + color = JetDeepNavy, + modifier = Modifier.weight(1f), + ) + + Row( + modifier = + Modifier.background(bg, RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isConfirmed) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + tint = txt, + modifier = Modifier.size(13.dp), + ) + } + Text( + text = status.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 10.sp, + ), + color = txt, + ) + } + } + + if (!description.isNullOrEmpty()) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 19.sp), + color = JetSlate, + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt new file mode 100644 index 00000000..c3858dc0 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Android Open Source Project + */ + +package com.example.jetpacker.feature.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentScope + +/** + * Dispatches an action merging context parameters with explicit payload values. + */ +internal fun A2uiComponentScope.dispatchMergedAction( + action: Map?, + defaultEventName: String, + extraContext: Map, +) { + val eventPayload = HashMap(extraContext) + if (action != null) { + val eventMap = (action["event"] as? Map<*, *>) ?: action + val contextMap = (eventMap["context"] as? Map<*, *>) ?: (action["context"] as? Map<*, *>) + contextMap?.forEach { (k, v) -> + if (k is String && v != null) eventPayload[k] = v + } + val eventName = (eventMap["name"] as? String) + ?: (action["eventName"] as? String) + ?: defaultEventName + eventPayload["eventName"] = eventName + } else { + eventPayload["eventName"] = defaultEventName + } + dispatchAction(eventPayload) +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogColorTokens.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogColorTokens.kt new file mode 100644 index 00000000..2f7955dc --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogColorTokens.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + */ + +package com.example.jetpacker.feature.booking_assistant.catalog + +import androidx.compose.ui.graphics.Color + +internal val JetSkyCyan = Color(0xFF72DAEC) +internal val JetSkyCyanSoft = Color(0xFFE0F7FA) +internal val JetDeepNavy = Color(0xFF1F3342) +internal val JetSlate = Color(0xFF41484B) +internal val JetMutedSlate = Color(0xFF677276) +internal val JetWarmCream = Color(0xFFFCF8E5) +internal val JetCardCream = Color(0xFFFFFEF8) +internal val JetTangelo = Color(0xFFFF5314) +internal val JetTangeloSoft = Color(0xFFFFF0E9) +internal val JetCoralOrange = Color(0xFFF16B43) +internal val JetRoyalCobalt = Color(0xFF306DAC) +internal val JetCobaltSoft = Color(0xFFE8EDFF) +internal val JetGold = Color(0xFFFFB51C) +internal val JetGoldLabel = Color(0xFFD5AD23) +internal val JetEmerald = Color(0xFF059669) +internal val JetEmeraldSoft = Color(0xFFD1FAE5) +internal val JetForestEmerald = Color(0xFF005543) +internal val JetEmeraldAccent = Color(0xFFB9DFCB) +internal val JetMintSoft = Color(0xFFECFCF4) +internal val JetHotelKeyCircleBg = Color(0xFF246B5A) diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/DigitalHotelKeyPassComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/DigitalHotelKeyPassComponent.kt new file mode 100644 index 00000000..5d67fe4e --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/DigitalHotelKeyPassComponent.kt @@ -0,0 +1,574 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Step 3 of Boutique Hotel Flow (`DigitalHotelKeyPass`): Morphs the surface in-place into a + * confirmed NFC Digital Room Key & Stay Pass with an interactive contactless key modal. + */ +class DigitalHotelKeyPassComponent : A2uiComponent { + private val hotelNameProp = + A2uiProperty.dynamicString(key = "hotelName", required = true, description = "Hotel name.") + private val roomNameProp = + A2uiProperty.dynamicString(key = "roomName", required = false, description = "Suite tier name.") + private val roomNumberProp = + A2uiProperty.dynamicString( + key = "roomNumber", + required = false, + description = "Assigned suite/room number.", + ) + private val floorPrefProp = + A2uiProperty.dynamicString( + key = "floorPreference", + required = false, + description = "Assigned floor & vibe.", + ) + private val checkInDateProp = + A2uiProperty.dynamicString( + key = "checkInDate", + required = false, + description = "Check-in date.", + ) + private val checkOutDateProp = + A2uiProperty.dynamicString( + key = "checkOutDate", + required = false, + description = "Check-out date.", + ) + private val checkInTimeProp = + A2uiProperty.dynamicString( + key = "checkInTime", + required = false, + description = "Confirmed check-in window.", + ) + private val confirmationCodeProp = + A2uiProperty.dynamicString( + key = "confirmationCode", + required = false, + description = "Hotel reservation confirmation code.", + ) + private val totalStayPriceProp = + A2uiProperty.dynamicNumber( + key = "totalStayPrice", + required = false, + description = "Total confirmed stay price.", + ) + private val breakfastProp = + A2uiProperty.dynamicBoolean( + key = "breakfastIncluded", + required = false, + description = "Breakfast package included.", + ) + private val lateCheckoutProp = + A2uiProperty.dynamicBoolean( + key = "lateCheckout", + required = false, + description = "4 PM late checkout included.", + ) + + override val name: String = "DigitalHotelKeyPass" + override val description: String = + "Confirmed hotel reservation pass with interactive NFC digital room key." + override val properties: List> = + listOf( + hotelNameProp, + roomNameProp, + roomNumberProp, + floorPrefProp, + checkInDateProp, + checkOutDateProp, + checkInTimeProp, + confirmationCodeProp, + totalStayPriceProp, + breakfastProp, + lateCheckoutProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(hotelNameProp) != null + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val hotelName = properties.bind(hotelNameProp) ?: "The Redwood Grove Hotel" + val roomName = properties.bind(roomNameProp) ?: "Redwood Canopy King Suite" + val roomNumber = properties.bind(roomNumberProp) ?: "SUITE 418" + val floorPref = properties.bind(floorPrefProp) ?: "High Floor • Quiet" + val checkInDate = properties.bind(checkInDateProp) ?: "OCT 27" + val checkOutDate = properties.bind(checkOutDateProp) ?: "OCT 31" + val checkInTime = properties.bind(checkInTimeProp) ?: "11:00 AM Early" + val confirmationCode = properties.bind(confirmationCodeProp) ?: "RG-8842" + val totalStayPrice = properties.bind(totalStayPriceProp)?.toInt() ?: 960 + val breakfast = properties.bind(breakfastProp) ?: false + val lateCheckout = properties.bind(lateCheckoutProp) ?: false + + var showNfcModal by remember { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(Color.White) + .border(BorderStroke(1.5.dp, Color(0xFF73C6B6)), RoundedCornerShape(22.dp)) + ) { + // Top Luxury Key Header + Box( + modifier = + Modifier.fillMaxWidth() + .background(Brush.linearGradient(colors = listOf(JetForestEmerald, Color(0xFF258B84)))) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) { + Icon( + imageVector = Icons.Rounded.Verified, + contentDescription = null, + tint = JetGoldLabel, + modifier = Modifier.size(15.dp), + ) + Text( + text = "DIGITAL ROOM KEY ISSUED", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetGoldLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Box( + modifier = + Modifier.background(Color.White.copy(alpha = 0.2f), RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = "CNF #$confirmationCode", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = Color.White, + ) + } + } + + Text( + text = hotelName, + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ), + color = Color.White, + ) + Text( + text = "$roomName • $floorPref", + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + color = Color.White.copy(alpha = 0.9f), + ) + } + } + + // Room Assignment + Dates Grid + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(JetMintSoft) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "ASSIGNED SUITE", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + Text( + text = roomNumber, + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Normal, + ), + color = JetForestEmerald, + ) + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "TOTAL PAID", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + Text( + text = "$$totalStayPrice", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Normal, + ), + color = JetDeepNavy, + ) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "CHECK-IN", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + Text( + text = "$checkInDate • $checkInTime", + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + color = JetDeepNavy, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "CHECK-OUT", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + Text( + text = "$checkOutDate • ${if (lateCheckout) "4:00 PM Late" else "11:00 AM"}", + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + color = JetDeepNavy, + ) + } + } + + // Included Perks Chips + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val perks = buildList { + add("⚡ Express Mobile Key") + add("🍷 VIP Welcome Amenity") + if (breakfast) add("🥐 Artisan Breakfast") + if (lateCheckout) add("🕓 4 PM Late Checkout") + } + perks.forEach { perk -> + Box( + modifier = + Modifier.background(Color(0xFFF1F4F8), RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = perk, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetDeepNavy, + ) + } + } + } + + Button( + onClick = { showNfcModal = true }, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = JetForestEmerald, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Rounded.QrCode2, + contentDescription = null, + tint = JetGoldLabel, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Open NFC Mobile Room Key", + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + ) + } + } + } + + if (showNfcModal) { + Dialog(onDismissRequest = { showNfcModal = false }) { + Column( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(JetForestEmerald) + .padding(horizontal = 24.dp, vertical = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = "HOLD NEAR DOOR LOCK", + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + color = JetGold, + ) + Text( + text = roomNumber, + style = + MaterialTheme.typography.headlineLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + ), + color = Color.White, + ) + Box( + modifier = + Modifier.size(101.dp) + .background(JetHotelKeyCircleBg, CircleShape) + .border(BorderStroke(2.dp, JetGold), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Hotel, + contentDescription = null, + tint = JetGold, + modifier = Modifier.size(42.dp), + ) + } + Text( + text = "$hotelName\n$roomName", + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + color = Color.White, + textAlign = TextAlign.Center, + ) + Button( + onClick = { showNfcModal = false }, + modifier = Modifier.fillMaxWidth().height(44.dp), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetGold, contentColor = JetDeepNavy), + ) { + Text( + text = "Done", + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + ) + } + } + } + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightAddonsCardComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightAddonsCardComponent.kt new file mode 100644 index 00000000..defbf2de --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightAddonsCardComponent.kt @@ -0,0 +1,348 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 2 & Act 3 Hybrid Add-ons Card: Showcases the Hybrid Catalog architecture by hosting standard + * `MaterialA2uiBasicCatalogV1Defaults.checkBox` child components (`childrenProp`) inside a styled + * JetPacker perks card, while also supporting direct two-way binding to `priorityBoarding` and + * `extraBag`. + */ +class FlightAddonsCardComponent : A2uiComponent { + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = false, + description = "Title for the flight add-ons section.", + ) + private val priorityBoardingProp = + A2uiProperty.dynamicBoolean( + key = "priorityBoarding", + required = false, + description = "Two-way bound Priority Boarding toggle (+$25).", + ) + private val extraBagProp = + A2uiProperty.dynamicBoolean( + key = "extraBag", + required = false, + description = "Two-way bound Extra Checked Bag toggle (+$45).", + ) + private val childrenProp = + A2uiProperty.childList( + key = "children", + required = false, + description = "Child Material 3 A2UI components (e.g. CheckBox controls).", + ) + + override val name: String = "FlightAddonsCard" + override val description: String = + "Hybrid container for flight add-on perks embedding standard Material 3 A2UI CheckBox controls." + override val properties: List> = + listOf(titleProp, priorityBoardingProp, extraBagProp, childrenProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean = true + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val title = properties.bind(titleProp) ?: "TRAVEL PERKS & UPGRADES" + val priorityBoarding = properties.bind(priorityBoardingProp) ?: false + val extraBag = properties.bind(extraBagProp) ?: false + val updatePriority = properties.bindUpdater(priorityBoardingProp) + val updateBag = properties.bindUpdater(extraBagProp) + val childRefs = properties.bindChildReferences(childrenProp).orEmpty() + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(Color.White) + .border(BorderStroke(1.dp, Color(0xFFE2E8F0)), RoundedCornerShape(20.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.Luggage, + contentDescription = null, + tint = JetRoyalCobalt, + modifier = Modifier.size(18.dp), + ) + Text( + text = title.uppercase(), + style = + MaterialTheme.typography.labelMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + letterSpacing = 0.5.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (childRefs.isNotEmpty()) { + // Render standard Material 3 A2UI CheckBox components streamed from the server + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + childRefs.forEachIndexed { idx, ref -> + val isActive = if (idx == 0) priorityBoarding else extraBag + val badgeText = if (idx == 0) "+$25" else "+$45" + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (isActive) JetCobaltSoft else JetWarmCream.copy(alpha = 0.65f)) + .border( + BorderStroke( + 1.dp, + if (isActive) JetRoyalCobalt.copy(alpha = 0.5f) else Color(0xFFE2E8F0), + ), + RoundedCornerShape(14.dp), + ) + .padding(start = 6.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + key(ref.id, ref.baseDataPath) { + when (val state = observeA2uiComponentState(id = ref.id)) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = true) + is A2uiComponentState.Error -> + Text("Perk unavailable", style = MaterialTheme.typography.bodySmall) + is A2uiComponentState.Success -> A2uiComponent(state.component) + } + } + } + Box( + modifier = + Modifier.background( + if (isActive) JetRoyalCobalt else Color.White, + RoundedCornerShape(50), + ) + .border( + BorderStroke(1.dp, JetRoyalCobalt.copy(alpha = 0.3f)), + RoundedCornerShape(50), + ) + .padding(horizontal = 9.dp, vertical = 4.dp) + ) { + Text( + text = badgeText, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + ), + color = if (isActive) Color.White else JetDeepNavy, + ) + } + } + } + } + } else { + // Fallback interactive perk toggles if no child CheckBox references were streamed + PerkToggleRow( + label = "Zone 1 Priority Boarding & FastTrack", + priceLabel = "+$25", + checked = priorityBoarding, + onCheckedChange = { updatePriority?.invoke(it) }, + ) + PerkToggleRow( + label = "Extra 23kg Checked Bag Allowance", + priceLabel = "+$45", + checked = extraBag, + onCheckedChange = { updateBag?.invoke(it) }, + ) + } + } + } +} + +@Composable +private fun PerkToggleRow( + label: String, + priceLabel: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (checked) JetCobaltSoft else JetWarmCream.copy(alpha = 0.65f)) + .border( + BorderStroke(1.dp, if (checked) JetRoyalCobalt else Color(0xFFE2E8F0)), + RoundedCornerShape(14.dp), + ) + .clickable { onCheckedChange(!checked) } + .padding(start = 6.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + colors = CheckboxDefaults.colors(checkedColor = JetRoyalCobalt, uncheckedColor = JetSlate), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = label, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + color = JetDeepNavy, + ) + } + Box( + modifier = + Modifier.background(if (checked) JetRoyalCobalt else Color.White, RoundedCornerShape(50)) + .border(BorderStroke(1.dp, JetRoyalCobalt.copy(alpha = 0.3f)), RoundedCornerShape(50)) + .padding(horizontal = 9.dp, vertical = 4.dp) + ) { + Text( + text = priceLabel, + style = + MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp), + color = if (checked) Color.White else JetDeepNavy, + ) + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightConfiguratorShellComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightConfiguratorShellComponent.kt new file mode 100644 index 00000000..48748a79 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightConfiguratorShellComponent.kt @@ -0,0 +1,591 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 1–3 Progressive Root Container: Renders the live animated route arc header (`originCode ✈ + * destCode`) immediately while observing `bodySlot` and `footerSlot` via + * `observeA2uiComponentState`. Any unresolved child slot renders a bespoke shimmer skeleton until + * its component definition arrives over SSE. + */ +class FlightConfiguratorShellComponent : A2uiComponent { + private val airlineProp = + A2uiProperty.dynamicString( + key = "airline", + required = false, + description = "Airline and flight number header.", + ) + private val originCodeProp = + A2uiProperty.dynamicString( + key = "originCode", + required = true, + description = "Origin 3-letter IATA airport code.", + ) + private val originCityProp = + A2uiProperty.dynamicString( + key = "originCity", + required = false, + description = "Origin city or terminal label.", + ) + private val destCodeProp = + A2uiProperty.dynamicString( + key = "destCode", + required = true, + description = "Destination 3-letter IATA airport code.", + ) + private val destCityProp = + A2uiProperty.dynamicString( + key = "destCity", + required = false, + description = "Destination city or terminal label.", + ) + private val statusBadgeProp = + A2uiProperty.dynamicString( + key = "statusBadge", + required = false, + description = "Live step status pill text.", + ) + private val bodySlotProp = + A2uiProperty.componentId( + key = "bodySlot", + required = false, + description = "Component ID for the main configurator body slot.", + ) + private val footerSlotProp = + A2uiProperty.componentId( + key = "footerSlot", + required = false, + description = "Component ID for the live fare summary footer slot.", + ) + + override val name: String = "FlightConfiguratorShell" + override val description: String = + "Progressive flight configurator container with animated trajectory arc and child slots." + override val properties: List> = + listOf( + airlineProp, + originCodeProp, + originCityProp, + destCodeProp, + destCityProp, + statusBadgeProp, + bodySlotProp, + footerSlotProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(originCodeProp) != null && properties.bind(destCodeProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val airline = properties.bind(airlineProp) ?: "JETPACKER AIR" + val originCode = properties.bind(originCodeProp) ?: "CDG" + val originCity = properties.bind(originCityProp) ?: "Origin" + val destCode = properties.bind(destCodeProp) ?: "SFO" + val destCity = properties.bind(destCityProp) ?: "Destination" + val bodySlotId = properties[bodySlotProp] + val footerSlotId = properties[footerSlotProp] + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Brush.verticalGradient(colors = listOf(Color(0xFFEAF7F5), JetWarmCream))) + .border(BorderStroke(1.dp, JetSkyCyan.copy(alpha = 0.75f)), RoundedCornerShape(24.dp)) + ) { + // Hero Route Trajectory Banner + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.FlightTakeoff, + contentDescription = null, + tint = JetDeepNavy, + modifier = Modifier.size(14.dp), + ) + Text( + text = airline.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Origin ✈ Animated Dotted Curve ✈ Destination + AnimatedFlightRouteHeader( + originCode = originCode, + originCity = originCity, + destCode = destCode, + destCity = destCity, + ) + } + } + + // Progressive Body Slot (Act 1 Shimmer -> Act 1 Flight Picker -> Act 2/3 Cabin Seat Map) + if (!bodySlotId.isNullOrEmpty()) { + val bodyState = observeA2uiComponentState(id = bodySlotId) + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + AnimatedContent( + targetState = bodyState, + transitionSpec = { + (fadeIn(animationSpec = tween(320)) + + scaleIn( + initialScale = 0.97f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + )) + .togetherWith(fadeOut(animationSpec = tween(180))) + .using(SizeTransform(clip = false)) + }, + label = "BodySlotTransition", + ) { state -> + when (state) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = false) + is A2uiComponentState.Error -> + Text( + text = "Unable to load cabin module.", + color = JetTangelo, + style = MaterialTheme.typography.bodySmall, + ) + is A2uiComponentState.Success -> A2uiComponent(state.component) + } + } + } + } + + // Progressive Footer Slot (Live Reactive Fare Summary Bar) + if (!footerSlotId.isNullOrEmpty()) { + val footerState = observeA2uiComponentState(id = footerSlotId) + Box( + modifier = + Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + ) { + AnimatedContent( + targetState = footerState, + transitionSpec = { + (fadeIn(animationSpec = tween(280)) + slideInVertically { it / 3 }) + .togetherWith(fadeOut(animationSpec = tween(150))) + .using(SizeTransform(clip = false)) + }, + label = "FooterSlotTransition", + ) { state -> + when (state) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = true) + is A2uiComponentState.Error -> Spacer(modifier = Modifier.height(4.dp)) + is A2uiComponentState.Success -> A2uiComponent(state.component) + } + } + } + } + } + } +} + +@Composable +internal fun AnimatedFlightRouteHeader( + originCode: String, + originCity: String, + destCode: String, + destCity: String, +) { + val infiniteTransition = rememberInfiniteTransition(label = "FlightArcTrajectory") + val flightProgress by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 2800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "FlightRouteProgress", + ) + val flightPainter = rememberVectorPainter(image = Icons.Rounded.Flight) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(0.32f)) { + Text( + text = originCode.uppercase(), + style = + MaterialTheme.typography.headlineLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + ), + color = JetDeepNavy, + textAlign = TextAlign.Center, + ) + Text( + text = originCity, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + ), + color = JetMutedSlate, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + + Box( + modifier = Modifier.weight(0.36f).height(46.dp).padding(horizontal = 4.dp), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxWidth().height(42.dp)) { + val startX = 8.dp.toPx() + val endX = size.width - 8.dp.toPx() + val midX = size.width / 2f + val baseY = size.height * 0.74f + val controlY = -size.height * 0.14f + + val arcPath = + Path().apply { + moveTo(startX, baseY) + quadraticTo(midX, controlY, endX, baseY) + } + drawPath( + path = arcPath, + color = JetDeepNavy.copy(alpha = 0.42f), + style = + Stroke( + width = 1.8.dp.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 10f), 0f), + ), + ) + + // Origin & Destination airport endpoints + drawCircle(color = JetDeepNavy, radius = 3.dp.toPx(), center = Offset(startX, baseY)) + drawCircle(color = JetDeepNavy, radius = 3.dp.toPx(), center = Offset(endX, baseY)) + + // Fade-in at origin (0..0.16), travel across arc, fade-out at destination (0.82..1.0) + val t = flightProgress + val markerAlpha = + when { + t < 0.16f -> (t / 0.16f).coerceIn(0f, 1f) + t > 0.82f -> ((1f - t) / 0.18f).coerceIn(0f, 1f) + else -> 1f + } + + val oneMinusT = 1f - t + val bx = oneMinusT * oneMinusT * startX + 2f * oneMinusT * t * midX + t * t * endX + val by = oneMinusT * oneMinusT * baseY + 2f * oneMinusT * t * controlY + t * t * baseY + + // Tangent vector along quadratic Bezier curve so airplane banks along trajectory + val dx = 2f * oneMinusT * (midX - startX) + 2f * t * (endX - midX) + val dy = 2f * oneMinusT * (controlY - baseY) + 2f * t * (baseY - controlY) + val angleDeg = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + 90f + + val centerOffset = Offset(bx, by) + val glowRadiusPx = 16.5.dp.toPx() + val circleRadiusPx = 12.dp.toPx() + val iconSizePx = 16.dp.toPx() + + + drawCircle( + brush = + Brush.radialGradient( + colors = + listOf( + JetCoralOrange.copy(alpha = 0.9f * markerAlpha), + JetCoralOrange.copy(alpha = 0.55f * markerAlpha), + Color.Transparent, + ), + center = centerOffset, + radius = glowRadiusPx, + ), + radius = glowRadiusPx, + center = centerOffset, + ) + drawCircle( + color = JetCoralOrange.copy(alpha = 0.65f * markerAlpha), + radius = 13.8.dp.toPx(), + center = centerOffset, + ) + + // 24.dp white badge circle + drawCircle( + color = Color.White.copy(alpha = markerAlpha), + radius = circleRadiusPx, + center = centerOffset, + ) + + // 16.dp airplane icon inside the moving circle + withTransform({ + translate(left = bx - iconSizePx / 2f, top = by - iconSizePx / 2f) + rotate(degrees = angleDeg, pivot = Offset(iconSizePx / 2f, iconSizePx / 2f)) + }) { + with(flightPainter) { + draw( + size = Size(iconSizePx, iconSizePx), + alpha = markerAlpha, + colorFilter = ColorFilter.tint(JetDeepNavy), + ) + } + } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(0.32f)) { + Text( + text = destCode.uppercase(), + style = + MaterialTheme.typography.headlineLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + ), + color = JetDeepNavy, + textAlign = TextAlign.Center, + ) + Text( + text = destCity, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + ), + color = JetMutedSlate, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +internal fun FlightSlotShimmerSkeleton(isFooter: Boolean) { + val transition = rememberInfiniteTransition(label = "SlotShimmer") + val shimmerAlpha by + transition.animateFloat( + initialValue = 0.35f, + targetValue = 0.85f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 850, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "ShimmerPulse", + ) + + if (isFooter) { + Row( + modifier = + Modifier.fillMaxWidth() + .height(62.dp) + .clip(RoundedCornerShape(20.dp)) + .background(JetDeepNavy.copy(alpha = 0.12f * shimmerAlpha + 0.08f)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = + Modifier.width(96.dp) + .height(10.dp) + .clip(RoundedCornerShape(6.dp)) + .background(JetDeepNavy.copy(alpha = 0.2f * shimmerAlpha)) + ) + Box( + modifier = + Modifier.width(64.dp) + .height(18.dp) + .clip(RoundedCornerShape(6.dp)) + .background(JetDeepNavy.copy(alpha = 0.28f * shimmerAlpha)) + ) + } + Box( + modifier = + Modifier.width(124.dp) + .height(38.dp) + .clip(RoundedCornerShape(14.dp)) + .background(JetSkyCyan.copy(alpha = 0.45f * shimmerAlpha)) + ) + } + } else { + Column( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(Color.White.copy(alpha = 0.7f)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.size(20.dp) + .clip(CircleShape) + .background(JetTangelo.copy(alpha = 0.35f * shimmerAlpha)) + ) + Box( + modifier = + Modifier.fillMaxWidth(0.75f) + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(JetDeepNavy.copy(alpha = 0.18f * shimmerAlpha)) + ) + } + repeat(2) { + Box( + modifier = + Modifier.fillMaxWidth() + .height(68.dp) + .clip(RoundedCornerShape(16.dp)) + .background(JetSkyCyan.copy(alpha = 0.22f * shimmerAlpha)) + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightOptionSelectorComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightOptionSelectorComponent.kt new file mode 100644 index 00000000..d12cff5b --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/FlightOptionSelectorComponent.kt @@ -0,0 +1,461 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 1 Interactive Flight Option Selector: Displays the itinerary-aware AI reasoning banner and + * interactive flight departure cards. Uses two-way data bindings (`bindUpdater`) so selecting a + * flight option immediately updates `/booking/selectedFlightId`, `/booking/selectedFlightTime`, and + * `/booking/baseFare` in the surface's reactive `A2uiDataModel`. + */ +class FlightOptionSelectorComponent : A2uiComponent { + private val aiReasonProp = + A2uiProperty.dynamicString( + key = "aiReason", + required = false, + description = "Itinerary-aware explanation generated by Gemini.", + ) + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "Pipe-delimited options: flightNum|timeRange|aircraftInfo|price|badge", + ) + private val selectedFlightIdProp = + A2uiProperty.dynamicString( + key = "selectedFlightId", + required = false, + description = "Two-way bound selected flight number.", + ) + private val selectedFlightTimeProp = + A2uiProperty.dynamicString( + key = "selectedFlightTime", + required = false, + description = "Two-way bound selected flight time range.", + ) + private val baseFareProp = + A2uiProperty.dynamicNumber( + key = "baseFare", + required = false, + description = "Two-way bound base fare in USD.", + ) + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Button label to lock in departure time.", + ) + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action dispatched when confirming flight departure.", + ) + + override val name: String = "FlightOptionSelector" + override val description: String = + "Selects a flight departure option with AI itinerary matching and two-way fare binding." + override val properties: List> = + listOf( + aiReasonProp, + optionsProp, + selectedFlightIdProp, + selectedFlightTimeProp, + baseFareProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val aiReason = properties.bind(aiReasonProp) + val rawOptions = properties.bind(optionsProp) ?: emptyList() + val boundFlightId = properties.bind(selectedFlightIdProp) + val boundFlightTime = properties.bind(selectedFlightTimeProp) + val updateFlightId = properties.bindUpdater(selectedFlightIdProp) + val updateFlightTime = properties.bindUpdater(selectedFlightTimeProp) + val updateBaseFare = properties.bindUpdater(baseFareProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Continue to Cabin & Seats" + val action = properties[actionProp] + + val parsedOptions = + remember(rawOptions) { rawOptions.mapIndexed { index, raw -> parseFlightOption(raw, index) } } + + var localSelectedId by + rememberSaveable(boundFlightId) { + mutableStateOf(boundFlightId ?: parsedOptions.firstOrNull()?.flightId.orEmpty()) + } + val activeSelectedId = boundFlightId ?: localSelectedId + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (!aiReason.isNullOrEmpty()) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(JetTangeloSoft) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = null, + tint = JetTangelo, + modifier = Modifier.size(18.dp), + ) + Text( + text = aiReason, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 18.sp), + color = JetDeepNavy, + ) + } + } + + Text( + text = "CHOOSE DEPARTURE FLIGHT", + style = + MaterialTheme.typography.labelMedium.copy( + fontFamily = SekuyaFontFamily, + fontSize = 13.sp, + letterSpacing = 0.7.sp, + ), + color = JetDeepNavy, + ) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + parsedOptions.forEach { item -> + val isSelected = + item.flightId.equals(activeSelectedId, ignoreCase = true) || + item.timeRange.equals(boundFlightTime, ignoreCase = true) + val borderColor by + animateColorAsState( + targetValue = if (isSelected) JetRoyalCobalt else Color(0xFFE9ECEF), + label = "FlightCardBorder", + ) + val containerColor by + animateColorAsState( + targetValue = if (isSelected) JetCobaltSoft else Color.White, + label = "FlightCardBg", + ) + + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(containerColor) + .border( + BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor), + RoundedCornerShape(16.dp), + ) + .clickable { + localSelectedId = item.flightId + updateFlightId?.invoke(item.flightId) + updateFlightTime?.invoke(item.timeRange) + updateBaseFare?.invoke(item.price) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (item.badge.isNotEmpty()) { + val isFeaturedBadge = + item.badge.contains("TOP PICK", ignoreCase = true) || + item.badge.contains("RECOMMENDED", ignoreCase = true) || + item.badge.contains("BEST", ignoreCase = true) + Box( + modifier = + Modifier.background( + if (isFeaturedBadge) JetCoralOrange else JetSkyCyan, + RoundedCornerShape(50), + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = item.badge, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = if (isFeaturedBadge) Color.White else JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = item.timeRange, + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${item.flightId} • ${item.aircraftInfo}", + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "$${item.price}", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + color = if (isSelected) JetRoyalCobalt else JetDeepNavy, + maxLines = 1, + ) + Text( + text = if (isSelected) "Selected" else "Roundtrip", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = if (isSelected) JetRoyalCobalt else JetMutedSlate, + maxLines = 1, + ) + } + } + } + } + + Button( + onClick = { + val chosen = + parsedOptions.find { it.flightId.equals(activeSelectedId, ignoreCase = true) } + ?: parsedOptions.firstOrNull() + val selectedLabel = + if (chosen != null) "${chosen.flightId} (${chosen.timeRange})" else activeSelectedId + val context = + mutableMapOf( + "type" to "confirm_option", + "selectionType" to "time", + "selectedOption" to selectedLabel, + ) + if (chosen != null) { + context["selectedFlightId"] = chosen.flightId + context["selectedFlightTime"] = chosen.timeRange + context["baseFare"] = chosen.price + } + dispatchMergedAction(action, "confirm_option", context) + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetDeepNavy, contentColor = Color.White), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + ) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + tint = JetSkyCyan, + modifier = Modifier.size(18.dp), + ) + } + } + } + } +} + +private data class ParsedFlightOption( + val flightId: String, + val timeRange: String, + val aircraftInfo: String, + val price: Int, + val badge: String, +) + +private fun parseFlightOption(raw: String, index: Int): ParsedFlightOption { + val parts = raw.split("|").map { it.trim() } + if (parts.size >= 4) { + val rawBadge = + parts + .getOrNull(4) + .orEmpty() + .replace(Regex("(?i)\\bAI\\s+PICK\\b"), "TOP PICK") + .replace(Regex("(?i)\\bAI\\s+RECOMMENDED\\b"), "TOP PICK") + .replace(Regex("(?i)\\bAI\\s+"), "") + return ParsedFlightOption( + flightId = parts[0], + timeRange = parts[1], + aircraftInfo = parts[2], + price = parts[3].filter { it.isDigit() }.toIntOrNull() ?: 640, + badge = rawBadge, + ) + } + return ParsedFlightOption( + flightId = "FL ${101 + index}", + timeRange = raw, + aircraftInfo = "Widebody Nonstop", + price = if (index == 0) 640 else 590, + badge = if (index == 0) "TOP PICK" else "", + ) +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelConfiguratorShellComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelConfiguratorShellComponent.kt new file mode 100644 index 00000000..55303f53 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelConfiguratorShellComponent.kt @@ -0,0 +1,417 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Progressive Root Container for the Boutique Hotel Stay Configurator (`HotelConfiguratorShell`). + * Showcases a distinct hospitality visual design (Deep Forest Emerald & Warm Gold) while sharing + * the same A2UI v0.9 progressive `bodySlot` / `footerSlot` architecture. + */ +class HotelConfiguratorShellComponent : A2uiComponent { + private val hotelNameProp = + A2uiProperty.dynamicString( + key = "hotelName", + required = true, + description = "Hotel property name.", + ) + private val locationProp = + A2uiProperty.dynamicString( + key = "location", + required = false, + description = "Neighborhood or city label.", + ) + private val checkInDateProp = + A2uiProperty.dynamicString( + key = "checkInDate", + required = false, + description = "Check-in date label (e.g., OCT 27).", + ) + private val checkOutDateProp = + A2uiProperty.dynamicString( + key = "checkOutDate", + required = false, + description = "Check-out date label (e.g., OCT 31).", + ) + private val nightsProp = + A2uiProperty.dynamicNumber(key = "nights", required = false, description = "Number of nights.") + private val statusBadgeProp = + A2uiProperty.dynamicString( + key = "statusBadge", + required = false, + description = "Live status pill text.", + ) + private val bodySlotProp = + A2uiProperty.componentId( + key = "bodySlot", + required = false, + description = "Component ID for the main hotel configurator body slot.", + ) + private val footerSlotProp = + A2uiProperty.componentId( + key = "footerSlot", + required = false, + description = "Component ID for the live stay rate footer slot.", + ) + + override val name: String = "HotelConfiguratorShell" + override val description: String = + "Progressive boutique hotel stay configurator shell with check-in timeline and child slots." + override val properties: List> = + listOf( + hotelNameProp, + locationProp, + checkInDateProp, + checkOutDateProp, + nightsProp, + statusBadgeProp, + bodySlotProp, + footerSlotProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(hotelNameProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val hotelName = properties.bind(hotelNameProp) ?: "The Redwood Grove Hotel" + val rawLocation = properties.bind(locationProp) ?: "San Francisco • 4-Star Boutique" + val location = + rawLocation.replace(Regex("(?i)${Regex.escape(hotelName)}\\s*[•·-]?\\s*"), "").trim().let { + cleaned -> + if (cleaned.isEmpty() || cleaned.equals("4-Star Boutique", ignoreCase = true)) { + "San Francisco • 4-Star Boutique" + } else { + cleaned + } + } + val checkInDate = properties.bind(checkInDateProp) ?: "OCT 27" + val checkOutDate = properties.bind(checkOutDateProp) ?: "OCT 31" + val nights = properties.bind(nightsProp)?.toInt() ?: 4 + val bodySlotId = properties[bodySlotProp] + val footerSlotId = properties[footerSlotProp] + + Column( + modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(19.dp)).background(JetWarmCream) + ) { + // Hospitality Hero Banner + Box( + modifier = + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier.size(28.dp).background(JetForestEmerald, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Hotel, + contentDescription = null, + tint = JetGold, + modifier = Modifier.size(15.dp), + ) + } + Column { + Text( + text = hotelName.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = location, + style = + MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, lineHeight = 14.sp), + color = JetMutedSlate, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // Stay Dates White Card (borderless nights pill + 17.sp CHECK-IN / CHECK-OUT matching + + Column( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(15.dp)) + .background(Color.White) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier.background(JetMintSoft, RoundedCornerShape(30.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "$nights NIGHTS STAY", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + lineHeight = 10.sp, + ), + color = JetForestEmerald, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "CHECK-IN", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 9.sp, + lineHeight = 13.sp, + ), + color = JetDeepNavy, + ) + Text( + text = checkInDate.uppercase(), + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = SekuyaFontFamily, + fontSize = 17.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.Normal, + ), + color = JetForestEmerald, + maxLines = 1, + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "CHECK-OUT", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 9.sp, + lineHeight = 13.sp, + ), + color = JetDeepNavy, + ) + Text( + text = checkOutDate.uppercase(), + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = SekuyaFontFamily, + fontSize = 17.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.Normal, + ), + color = JetForestEmerald, + maxLines = 1, + ) + } + } + } + } + } + + if (!bodySlotId.isNullOrEmpty()) { + val bodyState = observeA2uiComponentState(id = bodySlotId) + Box( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp) + ) { + AnimatedContent( + targetState = bodyState, + transitionSpec = { + (fadeIn(animationSpec = tween(320)) + + scaleIn( + initialScale = 0.97f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + )) + .togetherWith(fadeOut(animationSpec = tween(180))) + .using(SizeTransform(clip = false)) + }, + label = "HotelBodySlotTransition", + ) { state -> + when (state) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = false) + is A2uiComponentState.Error -> + Text( + text = "Unable to load suite options.", + color = JetTangelo, + style = MaterialTheme.typography.bodySmall, + ) + is A2uiComponentState.Success -> A2uiComponent(state.component) + } + } + } + } + + if (!footerSlotId.isNullOrEmpty()) { + val footerState = observeA2uiComponentState(id = footerSlotId) + Box( + modifier = + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 2.dp, bottom = 12.dp) + ) { + AnimatedContent( + targetState = footerState, + transitionSpec = { + (fadeIn(animationSpec = tween(280)) + slideInVertically { it / 3 }) + .togetherWith(fadeOut(animationSpec = tween(150))) + .using(SizeTransform(clip = false)) + }, + label = "HotelFooterSlotTransition", + ) { state -> + when (state) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = true) + is A2uiComponentState.Error -> Spacer(modifier = Modifier.height(4.dp)) + is A2uiComponentState.Success -> A2uiComponent(state.component) + } + } + } + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelLiveSummaryFooterComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelLiveSummaryFooterComponent.kt new file mode 100644 index 00000000..a938de99 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelLiveSummaryFooterComponent.kt @@ -0,0 +1,318 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Step 2 Sticky Footer (`HotelLiveSummaryFooter`): Reactively computes total stay cost + * (`nightlyRate * nights + breakfast + lateCheckout`) at 60fps directly from the local A2UI + * DataModel without server round-trips. + */ +class HotelLiveSummaryFooterComponent : A2uiComponent { + private val nightlyRateProp = + A2uiProperty.dynamicNumber( + key = "nightlyRate", + required = true, + description = "Nightly suite rate.", + ) + private val nightsProp = + A2uiProperty.dynamicNumber(key = "nights", required = false, description = "Number of nights.") + private val checkInTimeProp = + A2uiProperty.dynamicString( + key = "checkInTime", + required = false, + description = "Bound arrival check-in time.", + ) + private val floorPrefProp = + A2uiProperty.dynamicString( + key = "floorPreference", + required = false, + description = "Bound floor preference.", + ) + private val breakfastProp = + A2uiProperty.dynamicBoolean( + key = "breakfastIncluded", + required = false, + description = "Whether daily breakfast (+$28/nt) is enabled.", + ) + private val lateCheckoutProp = + A2uiProperty.dynamicBoolean( + key = "lateCheckout", + required = false, + description = "Whether 4 PM late checkout (+$45) is enabled.", + ) + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Confirm button text.", + ) + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action dispatched when confirming stay.", + ) + + override val name: String = "HotelLiveSummaryFooter" + override val description: String = + "Reactive stay total footer that updates instantaneously as hotel options are toggled." + override val properties: List> = + listOf( + nightlyRateProp, + nightsProp, + checkInTimeProp, + floorPrefProp, + breakfastProp, + lateCheckoutProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(nightlyRateProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val nightlyRate = properties.bind(nightlyRateProp)?.toInt() ?: 240 + val nights = properties.bind(nightsProp)?.toInt() ?: 4 + val checkInTime = properties.bind(checkInTimeProp) ?: "11:00 AM Early" + val floorPref = properties.bind(floorPrefProp) ?: "High Floor • Quiet" + val breakfast = properties.bind(breakfastProp) ?: false + val lateCheckout = properties.bind(lateCheckoutProp) ?: false + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Suite & Issue Key" + val action = properties[actionProp] + + val baseStay = nightlyRate * nights + val breakfastTotal = if (breakfast) 28 * nights else 0 + val lateTotal = if (lateCheckout) 45 else 0 + val totalStayPrice = baseStay + breakfastTotal + lateTotal + + val summaryParts = buildList { + add("$nights Nights ($$nightlyRate/nt)") + add(checkInTime) + if (breakfast) add("Breakfast (+$$breakfastTotal)") + if (lateCheckout) add("4PM Checkout (+$45)") + } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(JetForestEmerald) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "STAY TOTAL", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetGoldLabel, + ) + Text( + text = summaryParts.joinToString(" • "), + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = "$$totalStayPrice", + style = + MaterialTheme.typography.headlineSmall.copy( + fontFamily = SekuyaFontFamily, + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Normal, + ), + color = Color.White, + ) + } + + Button( + onClick = { + val context = + mutableMapOf( + "type" to "confirm_hotel_config", + "selectionType" to "confirm_hotel_config", + "checkInTime" to checkInTime, + "floorPreference" to floorPref, + "breakfastIncluded" to breakfast, + "lateCheckout" to lateCheckout, + "totalFare" to totalStayPrice, + ) + dispatchMergedAction(action, "confirm_hotel_config", context) + }, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = JetGold, contentColor = JetDeepNavy), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelRoomSelectorComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelRoomSelectorComponent.kt new file mode 100644 index 00000000..2bf0751a --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelRoomSelectorComponent.kt @@ -0,0 +1,424 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Step 1 of Boutique Hotel Flow (`HotelRoomSelector`): Renders 3 selectable room/suite tiers with + * amenity highlights, nightly rate, and AI match reason. Format of each string in `options`: + * `"roomId|roomName|amenities|nightlyRate|badge"` + */ +class HotelRoomSelectorComponent : A2uiComponent { + private val aiReasonProp = + A2uiProperty.dynamicString( + key = "aiReason", + required = false, + description = "AI concierge rationale for selecting this property.", + ) + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "Pipe-delimited suite options: roomId|roomName|amenities|nightlyRate|badge", + ) + private val selectedRoomIdProp = + A2uiProperty.dynamicString( + key = "selectedRoomId", + required = false, + description = "Two-way bound selected room ID.", + ) + private val selectedRoomNameProp = + A2uiProperty.dynamicString( + key = "selectedRoomName", + required = false, + description = "Two-way bound selected room name.", + ) + private val nightlyRateProp = + A2uiProperty.dynamicNumber( + key = "nightlyRate", + required = false, + description = "Two-way bound nightly rate.", + ) + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Primary CTA button text.", + ) + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action dispatched when room tier is selected.", + ) + + override val name: String = "HotelRoomSelector" + override val description: String = + "Suite and room tier selector with amenity chips and live data model binding." + override val properties: List> = + listOf( + aiReasonProp, + optionsProp, + selectedRoomIdProp, + selectedRoomNameProp, + nightlyRateProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val aiReason = + properties.bind(aiReasonProp) + ?: "Matched for proximity to your itinerary events and high guest tranquility scores." + val rawOptions = properties.bind(optionsProp) ?: emptyList() + val boundRoomId = properties.bind(selectedRoomIdProp) + val updateRoomId = properties.bindUpdater(selectedRoomIdProp) + val updateRoomName = properties.bindUpdater(selectedRoomNameProp) + val updateNightlyRate = properties.bindUpdater(nightlyRateProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Customize Stay & Check-In" + val action = properties[actionProp] + + data class ParsedRoom( + val id: String, + val name: String, + val amenities: String, + val rate: Int, + val badge: String, + ) + + val parsedRooms = + remember(rawOptions) { + rawOptions.mapIndexed { idx, raw -> + val parts = raw.split("|") + if (parts.size >= 5) { + ParsedRoom( + id = parts[0].trim(), + name = parts[1].trim(), + amenities = parts[2].trim(), + rate = parts[3].trim().toIntOrNull() ?: 240, + badge = + parts[4] + .trim() + .replace( + Regex("(?i)(AI\\s+PICK|TOP\\s+PICK)\\s*[•·-]\\s*5\\s*MIN\\s*TO\\s*VENUE"), + "POPULAR • BALCONY VIEW", + ) + .replace(Regex("(?i)\\b5\\s*MIN\\s*TO\\s*VENUE\\b"), "BALCONY VIEW") + .replace(Regex("(?i)\\bAI\\s+PICK\\b"), "POPULAR") + .replace(Regex("(?i)\\bAI\\s+RECOMMENDED\\b"), "MOST POPULAR") + .replace(Regex("(?i)\\bAI\\s+"), ""), + ) + } else { + ParsedRoom( + id = "suite_$idx", + name = raw, + amenities = "King Bed • High-Speed Wi-Fi • Courtyard View", + rate = 240, + badge = if (idx == 0) "POPULAR • BALCONY VIEW" else "AVAILABLE", + ) + } + } + } + + var localSelectedId by + rememberSaveable(boundRoomId) { + mutableStateOf(boundRoomId ?: parsedRooms.firstOrNull()?.id ?: "suite_0") + } + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + // AI Rationale Banner + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(JetMintSoft) + .border(BorderStroke(1.dp, JetEmeraldAccent), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = null, + tint = JetForestEmerald, + modifier = Modifier.size(15.dp), + ) + Text( + text = aiReason, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = JetForestEmerald, + ) + } + + parsedRooms.forEach { room -> + val isSelected = room.id == localSelectedId + Column( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFFFFEF8)) + .border( + BorderStroke( + if (isSelected) 2.dp else 1.dp, + if (isSelected) JetForestEmerald else Color(0xFFE0E4DE), + ), + RoundedCornerShape(16.dp), + ) + .clickable { + localSelectedId = room.id + updateRoomId?.invoke(room.id) + updateRoomName?.invoke(room.name) + updateNightlyRate?.invoke(room.rate) + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier.background( + if (isSelected) JetForestEmerald else Color(0xFFE9ECEF), + RoundedCornerShape(30.dp), + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = room.badge.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + lineHeight = 10.sp, + ), + color = if (isSelected) JetGold else Color(0xFF677276), + ) + } + + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = "$${room.rate}", + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = SekuyaFontFamily, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Normal, + ), + color = JetForestEmerald, + ) + Text( + text = " / night", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 14.sp, + ), + color = JetDeepNavy, + ) + } + } + + Text( + text = room.name, + style = + MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 19.sp, + ), + color = JetDeepNavy, + ) + + Text( + text = room.amenities, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.25.sp, + ), + color = Color(0xFF48473F), + ) + } + } + + val activeRoom = parsedRooms.find { it.id == localSelectedId } ?: parsedRooms.firstOrNull() + Button( + onClick = { + val context = + mutableMapOf( + "type" to "select_hotel_room", + "selectionType" to "hotel_room", + "selectedRoomId" to (activeRoom?.id ?: "suite_0"), + "selectedOption" to (activeRoom?.name ?: "Redwood Canopy King Suite"), + "nightlyRate" to (activeRoom?.rate ?: 240), + ) + dispatchMergedAction(action, "select_hotel_room", context) + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetForestEmerald, contentColor = Color.White), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelStayCustomizerComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelStayCustomizerComponent.kt new file mode 100644 index 00000000..473f50f7 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/HotelStayCustomizerComponent.kt @@ -0,0 +1,403 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Step 2 of Boutique Hotel Flow (`HotelStayCustomizer`): Interactive Check-In Arrival Window + * selector + Floor & Vibe selector + Live Front Desk VIP server patch banner + hybrid Material 3 + * CheckBox add-ons slot. + */ +class HotelStayCustomizerComponent : A2uiComponent { + private val selectedRoomNameProp = + A2uiProperty.dynamicString( + key = "selectedRoomName", + required = false, + description = "Selected suite name.", + ) + private val checkInTimeProp = + A2uiProperty.dynamicString( + key = "checkInTime", + required = false, + description = "Two-way bound check-in time window.", + ) + private val floorPrefProp = + A2uiProperty.dynamicString( + key = "floorPreference", + required = false, + description = "Two-way bound floor & vibe preference.", + ) + private val liveAlertProp = + A2uiProperty.dynamicString( + key = "liveAlert", + required = false, + description = "Live server patch notification from hotel front desk.", + ) + private val childrenProp = + A2uiProperty.childList( + key = "children", + required = false, + description = "Child A2UI CheckBox components for stay add-ons.", + ) + + override val name: String = "HotelStayCustomizer" + override val description: String = + "Interactive hotel stay customizer with arrival time pills, floor vibe selector, and add-on slots." + override val properties: List> = + listOf(selectedRoomNameProp, checkInTimeProp, floorPrefProp, liveAlertProp, childrenProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean = true + + @OptIn(ExperimentalLayoutApi::class) + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val roomName = properties.bind(selectedRoomNameProp) ?: "Redwood Canopy King Suite" + val boundCheckIn = properties.bind(checkInTimeProp) ?: "11:00 AM Early" + val updateCheckIn = properties.bindUpdater(checkInTimeProp) + val boundFloor = properties.bind(floorPrefProp) ?: "High Floor • Quiet" + val updateFloor = properties.bindUpdater(floorPrefProp) + val liveAlert = properties.bind(liveAlertProp).orEmpty() + val childRefs = properties.bindChildReferences(childrenProp).orEmpty() + + var selectedCheckIn by rememberSaveable(boundCheckIn) { mutableStateOf(boundCheckIn) } + var selectedFloor by rememberSaveable(boundFloor) { mutableStateOf(boundFloor) } + + val checkInWindows = listOf("11:00 AM Early", "3:00 PM Standard", "7:00 PM Evening") + val floorVibes = listOf("High Floor • Quiet", "Redwood View", "Poolside Access") + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Selected Suite Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "STAY CUSTOMIZATION", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetDeepNavy, + ) + Text( + text = roomName, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + color = JetDeepNavy, + ) + } + } + + // Live Server Data Patch Banner (when front desk unlocks VIP perk) + AnimatedVisibility( + visible = liveAlert.isNotBlank(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(JetMintSoft) + .border(BorderStroke(1.dp, JetEmeraldAccent), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Rounded.Verified, + contentDescription = null, + tint = JetForestEmerald, + modifier = Modifier.size(16.dp), + ) + Text( + text = liveAlert, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetForestEmerald, + ) + } + } + + // 1. Check-In Arrival Window Picker + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "ARRIVAL & CHECK-IN WINDOW", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + checkInWindows.forEach { window -> + val isChosen = window == selectedCheckIn + Box( + modifier = + Modifier.clip(RoundedCornerShape(9.dp)) + .background(if (isChosen) JetForestEmerald else JetCardCream) + .border( + BorderStroke(1.dp, if (isChosen) JetForestEmerald else Color(0xFFD5D9DF)), + RoundedCornerShape(9.dp), + ) + .clickable { + selectedCheckIn = window + updateCheckIn?.invoke(window) + } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = window, + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + color = if (isChosen) Color.White else JetDeepNavy, + ) + } + } + } + } + + // 2. Floor & Vibe Preference Picker + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "FLOOR & ROOM VIBE", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetMutedSlate, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + floorVibes.forEach { vibe -> + val isChosen = vibe == selectedFloor + Box( + modifier = + Modifier.clip(RoundedCornerShape(9.dp)) + .background(if (isChosen) JetMintSoft else JetCardCream) + .border( + BorderStroke( + if (isChosen) 1.5.dp else 1.dp, + if (isChosen) JetForestEmerald else Color(0xFFD5D9DF), + ), + RoundedCornerShape(9.dp), + ) + .clickable { + selectedFloor = vibe + updateFloor?.invoke(vibe) + } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = vibe, + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + color = if (isChosen) JetForestEmerald else JetDeepNavy, + ) + } + } + } + } + + // 3. Hybrid Catalog Standard Material 3 CheckBox Children (Breakfast + Late Checkout) + if (childRefs.isNotEmpty()) { + Column( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(JetCardCream) + .border(BorderStroke(1.dp, Color(0xFFE9ECEF)), RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "HOSPITALITY PACKAGES", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + color = JetForestEmerald, + modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 2.dp), + ) + childRefs.forEach { ref -> + key(ref.id, ref.baseDataPath) { + when (val childState = observeA2uiComponentState(id = ref.id)) { + is A2uiComponentState.Loading -> FlightSlotShimmerSkeleton(isFooter = true) + is A2uiComponentState.Error -> Spacer(modifier = Modifier.height(2.dp)) + is A2uiComponentState.Success -> A2uiComponent(childState.component) + } + } + } + } + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/InteractiveOptionPickerComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/InteractiveOptionPickerComponent.kt new file mode 100644 index 00000000..4226d935 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/InteractiveOptionPickerComponent.kt @@ -0,0 +1,350 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * An interactive option picker that allows selecting a single option from a list and confirming + * (used for Dining party size, Activities, or fallback flight selection). + */ +class InteractiveOptionPickerComponent : A2uiComponent { + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = false, + description = "The title of the booking item.", + ) + + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "The booking category.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = false, + description = "Status chip label (e.g. CONFIRMATION REQUIRED).", + ) + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = true, + description = "The prompt or instruction to display to the user.", + ) + + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "The list of selectable options.", + ) + + private val selectedOptionProp = + A2uiProperty.dynamicString( + key = "selectedOption", + required = false, + description = "The currently selected option string.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "The label for the confirmation button.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Custom action payload dispatched when the confirm button is clicked.", + ) + + override val name: String = "InteractiveOptionPicker" + override val description: String = + "An interactive option picker allowing users to select an option and confirm." + override val properties: List> = + listOf( + titleProp, + categoryProp, + statusProp, + promptProp, + optionsProp, + selectedOptionProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(promptProp) != null && properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val title = properties.bind(titleProp) + val category = properties.bind(categoryProp) ?: "Dining" + val status = properties.bind(statusProp) ?: "ACTION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Select an option:" + val options = properties.bind(optionsProp) ?: emptyList() + val boundSelected = properties.bind(selectedOptionProp) + val updateSelected = properties.bindUpdater(selectedOptionProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" + val action = properties[actionProp] + + var selected by + rememberSaveable(boundSelected) { mutableStateOf(boundSelected ?: options.firstOrNull()) } + + val categoryIcon = + when { + category.contains("Hotel", ignoreCase = true) -> Icons.Rounded.Hotel + category.contains("Activity", ignoreCase = true) -> Icons.Rounded.ConfirmationNumber + category.contains("Dining", ignoreCase = true) -> Icons.Rounded.Restaurant + else -> Icons.Rounded.Flight + } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(JetWarmCream.copy(alpha = 0.65f)) + .border(BorderStroke(1.dp, Color(0xFFE2E8F0)), RoundedCornerShape(20.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = JetDeepNavy, + modifier = Modifier.weight(1f), + ) + } + Box( + modifier = + Modifier.background(JetTangeloSoft, RoundedCornerShape(50)) + .border(BorderStroke(1.dp, JetTangelo.copy(alpha = 0.35f)), RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = status.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 10.sp, + ), + color = JetTangelo, + ) + } + } + + Text( + text = prompt, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 20.sp), + color = JetSlate, + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + val isSelected = option == selected + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(if (isSelected) JetCobaltSoft else Color.White) + .border( + BorderStroke( + if (isSelected) 2.dp else 1.dp, + if (isSelected) JetRoyalCobalt else Color(0xFFD1D5DB), + ), + RoundedCornerShape(16.dp), + ) + .clickable { + selected = option + updateSelected?.invoke(option) + } + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = categoryIcon, + contentDescription = null, + tint = if (isSelected) JetRoyalCobalt else JetSlate, + modifier = Modifier.size(20.dp), + ) + Text( + text = option, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 15.sp, + ), + color = JetDeepNavy, + modifier = Modifier.weight(1f), + ) + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + Button( + onClick = { + val context = mutableMapOf("type" to "confirm_option") + selected?.let { context["selectedOption"] = it } + dispatchMergedAction(action, "confirm_option", context) + }, + enabled = selected != null, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetDeepNavy, contentColor = Color.White), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp), + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/LiveFareSummaryFooterComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/LiveFareSummaryFooterComponent.kt new file mode 100644 index 00000000..1dca8ddb --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/LiveFareSummaryFooterComponent.kt @@ -0,0 +1,335 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 2 & Act 3 Sticky Reactive Fare Summary Footer: Subscribes to `/booking/baseFare`, + * `/booking/seat`, `/booking/seatUpcharge`, `/booking/priorityBoarding`, and `/booking/extraBag` + * via `properties.bind(...)`. Computes the live total on the client with zero network round-trips + * and animates digit changes using `AnimatedContent`. + */ +class LiveFareSummaryFooterComponent : A2uiComponent { + private val baseFareProp = + A2uiProperty.dynamicNumber( + key = "baseFare", + required = true, + description = "Base flight fare in USD.", + ) + private val selectedSeatProp = + A2uiProperty.dynamicString( + key = "selectedSeat", + required = false, + description = "Currently selected seat designator.", + ) + private val seatUpchargeProp = + A2uiProperty.dynamicNumber( + key = "seatUpcharge", + required = false, + description = "Upgrade fee for the selected seat.", + ) + private val priorityBoardingProp = + A2uiProperty.dynamicBoolean( + key = "priorityBoarding", + required = false, + description = "Whether Priority Boarding (+$25) is selected.", + ) + private val extraBagProp = + A2uiProperty.dynamicBoolean( + key = "extraBag", + required = false, + description = "Whether Extra Checked Bag (+$45) is selected.", + ) + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Confirm button text.", + ) + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action dispatched when confirming the complete flight configuration.", + ) + + override val name: String = "LiveFareSummaryFooter" + override val description: String = + "Reactive live fare calculator bar bound to seat and perk selections in the A2uiDataModel." + override val properties: List> = + listOf( + baseFareProp, + selectedSeatProp, + seatUpchargeProp, + priorityBoardingProp, + extraBagProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(baseFareProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val baseFare = properties.bind(baseFareProp)?.toInt() ?: 640 + val selectedSeat = properties.bind(selectedSeatProp) ?: "1A" + val seatUpcharge = properties.bind(seatUpchargeProp)?.toInt() ?: 0 + val priorityBoarding = properties.bind(priorityBoardingProp) ?: false + val extraBag = properties.bind(extraBagProp) ?: false + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Issue Pass" + val action = properties[actionProp] + + val totalFare = + baseFare + seatUpcharge + (if (priorityBoarding) 25 else 0) + (if (extraBag) 45 else 0) + + val breakdownParts = buildList { + add("Seat $selectedSeat" + if (seatUpcharge > 0) " (+$$seatUpcharge)" else "") + if (priorityBoarding) add("Priority (+$25)") + if (extraBag) add("+1 Bag (+$45)") + } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(JetDeepNavy) + .border(BorderStroke(1.dp, JetSkyCyan.copy(alpha = 0.5f)), RoundedCornerShape(22.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "TOTAL FARE", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + fontSize = 9.sp, + ), + color = JetSkyCyan, + ) + + Text( + text = breakdownParts.joinToString(" • "), + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + AnimatedContent( + targetState = totalFare, + transitionSpec = { + if (targetState > initialState) { + (slideInVertically { height -> height } + fadeIn()).togetherWith( + slideOutVertically { height -> -height } + fadeOut() + ) + } else { + (slideInVertically { height -> -height } + fadeIn()).togetherWith( + slideOutVertically { height -> height } + fadeOut() + ) + } + .using(SizeTransform(clip = false)) + }, + label = "RollingTotalFare", + ) { targetTotal -> + Text( + text = "$$targetTotal", + style = + MaterialTheme.typography.headlineSmall.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + color = Color.White, + maxLines = 1, + ) + } + } + + Button( + onClick = { + val context = + mutableMapOf( + "type" to "confirm_seat", + "selectionType" to "confirm_flight_config", + "selectedSeat" to selectedSeat, + "seat" to selectedSeat, + "totalFare" to totalFare, + "priorityBoarding" to priorityBoarding, + "extraBag" to extraBag, + ) + dispatchMergedAction(action, "confirm_flight_config", context) + }, + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetSkyCyan, contentColor = JetDeepNavy), + modifier = Modifier.fillMaxWidth().height(46.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + maxLines = 1, + ) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/PerforatedBoardingPassComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/PerforatedBoardingPassComponent.kt new file mode 100644 index 00000000..74a43679 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/PerforatedBoardingPassComponent.kt @@ -0,0 +1,667 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** + * Act 4 Perforated Boarding Pass Card: The confirmed finale surface featuring semicircular ticket + * punch-out notches, `SekuyaFontFamily` airport codes, 4-column gate/seat telemetry grid, perk + * chips, and an interactive tap-to-enlarge QR pass (`CoreUiR.drawable.qr`). + */ +class PerforatedBoardingPassComponent : A2uiComponent { + private val airlineProp = + A2uiProperty.dynamicString( + "airline", + required = false, + description = "Airline & flight number.", + ) + private val originCodeProp = + A2uiProperty.dynamicString("originCode", required = true, description = "Origin IATA code.") + private val originCityProp = + A2uiProperty.dynamicString("originCity", required = false, description = "Origin city name.") + private val destCodeProp = + A2uiProperty.dynamicString("destCode", required = true, description = "Destination IATA code.") + private val destCityProp = + A2uiProperty.dynamicString("destCity", required = false, description = "Destination city name.") + private val flightTimeProp = + A2uiProperty.dynamicString("flightTime", required = false, description = "Flight time slot.") + private val seatProp = + A2uiProperty.dynamicString("seat", required = true, description = "Confirmed seat designator.") + private val gateProp = + A2uiProperty.dynamicString("gate", required = false, description = "Departure gate.") + private val terminalProp = + A2uiProperty.dynamicString("terminal", required = false, description = "Departure terminal.") + private val boardingTimeProp = + A2uiProperty.dynamicString("boardingTime", required = false, description = "Boarding time.") + private val confirmationCodeProp = + A2uiProperty.dynamicString("confirmationCode", required = false, description = "PNR reference.") + private val totalFareProp = + A2uiProperty.dynamicNumber("totalFare", required = false, description = "Total fare paid.") + private val priorityBoardingProp = + A2uiProperty.dynamicBoolean( + "priorityBoarding", + required = false, + description = "Priority perk.", + ) + private val extraBagProp = + A2uiProperty.dynamicBoolean("extraBag", required = false, description = "Extra bag perk.") + + override val name: String = "PerforatedBoardingPass" + override val description: String = + "Confirmed perforated boarding pass with QR code, gate telemetry, and selected seat/perks." + override val properties: List> = + listOf( + airlineProp, + originCodeProp, + originCityProp, + destCodeProp, + destCityProp, + flightTimeProp, + seatProp, + gateProp, + terminalProp, + boardingTimeProp, + confirmationCodeProp, + totalFareProp, + priorityBoardingProp, + extraBagProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(originCodeProp) != null && properties.bind(destCodeProp) != null + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val airline = properties.bind(airlineProp) ?: "UNITED • UA 991" + val originCode = properties.bind(originCodeProp) ?: "CDG" + val originCity = properties.bind(originCityProp) ?: "Paris" + val destCode = properties.bind(destCodeProp) ?: "SFO" + val destCity = properties.bind(destCityProp) ?: "San Francisco" + val flightTime = properties.bind(flightTimeProp) ?: "10:15 AM – 12:30 PM" + val seat = properties.bind(seatProp) ?: "1A" + val gate = properties.bind(gateProp) ?: "K42" + val terminal = properties.bind(terminalProp) ?: "2E" + val boardingTime = properties.bind(boardingTimeProp) ?: "09:35 AM" + val rawConfirmationCode = properties.bind(confirmationCodeProp) ?: "UA-88420-X" + val confirmationCode = rawConfirmationCode.replace(Regex("(?i)^A2UI-"), "UA-") + val totalFare = properties.bind(totalFareProp)?.toInt() ?: 725 + val priorityBoarding = properties.bind(priorityBoardingProp) ?: false + val extraBag = properties.bind(extraBagProp) ?: false + val cleanTerminal = terminal.replace(Regex("(?i)^terminal\\s*"), "").trim().ifEmpty { "2E" } + val cleanGate = gate.replace(Regex("(?i)^gate\\s*"), "").trim().ifEmpty { "K42" } + + var showQrModal by remember { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .border(BorderStroke(1.5.dp, JetSkyCyan), RoundedCornerShape(24.dp)) + ) { + // Top Sky Cyan Boarding Pass Header + Column( + modifier = + Modifier.fillMaxWidth() + .background( + Brush.verticalGradient(colors = listOf(JetSkyCyan.copy(alpha = 0.65f), Color.White)) + ) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.Verified, + contentDescription = null, + tint = JetEmerald, + modifier = Modifier.size(18.dp), + ) + Text( + text = airline.uppercase(), + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.5.sp, + fontSize = 11.5.sp, + ), + color = JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Box( + modifier = + Modifier.background(JetEmeraldSoft, RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = "BOARDING PASS", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.ExtraBold, + fontSize = 9.5.sp, + letterSpacing = 0.4.sp, + ), + color = JetEmerald, + maxLines = 1, + softWrap = false, + ) + } + } + + AnimatedFlightRouteHeader( + originCode = originCode, + originCity = originCity, + destCode = destCode, + destCity = destCity, + ) + + Text( + text = flightTime, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), + color = JetDeepNavy, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + + // 4-Column Gate / Terminal / Boarding / Seat Telemetry Grid + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(JetWarmCream) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BoardingPassStatCol( + label = "TERMINAL", + value = cleanTerminal, + modifier = Modifier.weight(1f), + ) + BoardingPassStatCol(label = "GATE", value = cleanGate, modifier = Modifier.weight(1f)) + BoardingPassStatCol( + label = "BOARDING", + value = boardingTime, + modifier = Modifier.weight(1.3f), + ) + BoardingPassStatCol( + label = "SEAT", + value = seat, + highlight = true, + modifier = Modifier.weight(1f), + ) + } + + // Active Perks & Upgrades Chips + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val isSuite = seat.startsWith("1") || seat.startsWith("2") + BoardingPassPerkChip( + text = if (isSuite) "✦ Business Suite $seat" else "✦ Comfort+ Seat $seat", + bgColor = JetCobaltSoft, + textColor = JetRoyalCobalt, + ) + if (priorityBoarding) { + BoardingPassPerkChip( + text = "✦ Zone 1 Priority", + bgColor = Color(0xFFFFF8E1), + textColor = Color(0xFFB45309), + ) + } + if (extraBag) { + BoardingPassPerkChip( + text = "✦ +1 Checked Bag (23kg)", + bgColor = JetEmeraldSoft, + textColor = JetEmerald, + ) + } + } + + // Perforated Ticket Tear Line with Semicircular Side Cutout Notches + Canvas(modifier = Modifier.fillMaxWidth().height(24.dp)) { + val midY = size.height / 2f + val notchRadius = 11.dp.toPx() + + drawLine( + color = JetMutedSlate.copy(alpha = 0.55f), + start = Offset(notchRadius + 8.dp.toPx(), midY), + end = Offset(size.width - notchRadius - 8.dp.toPx(), midY), + strokeWidth = 1.5.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f), 0f), + ) + // Left & right ticket punch notches matching the outer card background + drawCircle(color = JetCardCream, radius = notchRadius, center = Offset(0f, midY)) + drawCircle(color = JetCardCream, radius = notchRadius, center = Offset(size.width, midY)) + drawCircle( + color = JetSkyCyan, + radius = notchRadius, + center = Offset(0f, midY), + style = Stroke(width = 1.5.dp.toPx()), + ) + drawCircle( + color = JetSkyCyan, + radius = notchRadius, + center = Offset(size.width, midY), + style = Stroke(width = 1.5.dp.toPx()), + ) + } + + // Bottom Tear-Off Stub with QR Code & Total Paid + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { showQrModal = true } + .padding(start = 18.dp, end = 18.dp, bottom = 16.dp, top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "CONFIRMATION • $confirmationCode", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + fontSize = 10.sp, + ), + color = JetSlate, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Total Paid • $$totalFare", + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + color = JetDeepNavy, + maxLines = 1, + ) + Text( + text = "Tap QR pass to enlarge for gate scanner", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = JetRoyalCobalt, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Box( + modifier = + Modifier.size(58.dp) + .clip(RoundedCornerShape(14.dp)) + .background(JetWarmCream) + .border(BorderStroke(1.dp, JetDeepNavy.copy(alpha = 0.2f)), RoundedCornerShape(14.dp)) + .padding(6.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = CoreUiR.drawable.qr), + contentDescription = "Boarding Pass QR Code", + contentScale = ContentScale.Fit, + modifier = Modifier.size(46.dp), + ) + } + } + } + + if (showQrModal) { + Dialog(onDismissRequest = { showQrModal = false }) { + Surface( + shape = RoundedCornerShape(28.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = originCode.uppercase(), + style = + MaterialTheme.typography.headlineLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + ), + color = JetDeepNavy, + ) + Icon( + imageVector = Icons.Rounded.Flight, + contentDescription = null, + tint = JetDeepNavy, + modifier = Modifier.size(28.dp).rotate(90f), + ) + Text( + text = destCode.uppercase(), + style = + MaterialTheme.typography.headlineLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + ), + color = JetDeepNavy, + ) + } + + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFFB0C4D8))) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Gate $cleanGate", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + color = JetDeepNavy, + ) + Text( + text = "Seat $seat", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + color = JetDeepNavy, + ) + } + } + + Image( + painter = painterResource(id = CoreUiR.drawable.qr), + contentDescription = "Full Boarding Pass QR", + modifier = Modifier.size(160.dp).padding(vertical = 4.dp), + contentScale = ContentScale.Fit, + ) + + val airlineName = + airline + .substringBefore("•") + .trim() + .lowercase() + .replaceFirstChar { it.uppercase() } + .ifEmpty { "United Airlines" } + val flightNumbers = airline.substringAfter("•", "UA 1003 • UA 1007").trim() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = airlineName, + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + color = JetMutedSlate, + ) + Text( + text = flightNumbers, + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + color = JetMutedSlate, + ) + } + + Button( + onClick = { showQrModal = false }, + colors = ButtonDefaults.buttonColors(containerColor = JetDeepNavy), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth().height(48.dp), + ) { + Text( + text = "Done", + color = JetCardCream, + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + ) + } + } + } + } + } + } +} + +@Composable +private fun BoardingPassStatCol( + label: String, + value: String, + highlight: Boolean = false, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 0.4.sp, + ), + color = JetSlate, + maxLines = 1, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 13.5.sp, + ), + color = if (highlight) JetTangelo else JetDeepNavy, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun BoardingPassPerkChip(text: String, bgColor: Color, textColor: Color) { + Box( + modifier = + Modifier.background(bgColor, RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = text, + style = + MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 11.sp), + color = textColor, + ) + } +} + +/** + * Helper to merge custom action context into an A2UI action map and dispatch it via + * [A2uiComponentScope.dispatchAction]. + */ + + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/SeatSelectionPickerComponent.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/SeatSelectionPickerComponent.kt new file mode 100644 index 00000000..59e22ed7 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/SeatSelectionPickerComponent.kt @@ -0,0 +1,321 @@ +/* + * 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.booking_assistant.catalog + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiComponentState +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.AirlineSeatReclineExtra +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.FlightTakeoff +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.catalog.MaterialA2uiBasicCatalogV1Defaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import kotlin.math.atan2 + + +/** A seat selection picker for flights or travel segments. */ +class SeatSelectionPickerComponent : A2uiComponent { + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = false, + description = "The title of the booking item.", + ) + + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = false, + description = "Status chip label (e.g. ACTION REQUIRED).", + ) + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = false, + description = "The prompt for seat selection.", + ) + + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "Available seats list.", + ) + + private val selectedSeatProp = + A2uiProperty.dynamicString( + key = "selectedSeat", + required = false, + description = "Currently selected seat.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Button label for confirmation.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action payload dispatched when confirming seat selection.", + ) + + override val name: String = "SeatSelectionPicker" + override val description: String = + "A seat selection picker displaying seat options and a confirmation button." + override val properties: List> = + listOf( + titleProp, + categoryProp, + statusProp, + promptProp, + optionsProp, + selectedSeatProp, + confirmBtnTextProp, + actionProp, + ) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content(properties: A2uiComponentProperties, modifier: Modifier) { + val title = properties.bind(titleProp) + val status = properties.bind(statusProp) ?: "ACTION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Seat Selection" + val options = properties.bind(optionsProp) ?: listOf("1A", "1B", "2A", "2B") + val initialSeat = properties.bind(selectedSeatProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" + val action = properties[actionProp] + + var selectedSeat by rememberSaveable(initialSeat) { mutableStateOf(initialSeat) } + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = JetDeepNavy, + ) + } + + Box( + modifier = + Modifier.background(JetTangeloSoft, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = status.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 11.sp, + ), + color = JetTangelo, + ) + } + + Text( + text = prompt, + style = + MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp), + color = JetDeepNavy, + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.chunked(4).forEach { rowSeats -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowSeats.forEach { seat -> + val isSelected = seat == selectedSeat + OutlinedButton( + onClick = { selectedSeat = seat }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + colors = + if (isSelected) { + ButtonDefaults.outlinedButtonColors( + containerColor = JetDeepNavy, + contentColor = Color.White, + ) + } else { + ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = JetDeepNavy, + ) + }, + border = if (isSelected) null else BorderStroke(1.dp, Color(0xFFD1D5DB)), + ) { + Text( + text = seat, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = SekuyaFontFamily, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Button( + onClick = { + val context = mutableMapOf("type" to "confirm_seat") + selectedSeat?.let { + context["selectedSeat"] = it + context["seat"] = it + } + dispatchMergedAction(action, "confirm_seat", context) + }, + enabled = selectedSeat != null, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors(containerColor = JetDeepNavy, contentColor = Color.White), + ) { + Text( + text = confirmBtnText, + style = + MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp), + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/BookingChatComposer.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/BookingChatComposer.kt new file mode 100644 index 00000000..883995ec --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/BookingChatComposer.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp + +@Composable +fun BookingChatComposer( + inputText: String, + onInputTextChange: (String) -> Unit, + onSendMessage: (String) -> Unit, + isStreaming: Boolean, + modifier: Modifier = Modifier, + onVoiceClick: (() -> Unit)? = null, +) { + val focusManager = LocalFocusManager.current + + fun submit() { + if (inputText.isNotBlank() && !isStreaming) { + val message = inputText + onInputTextChange("") + focusManager.clearFocus() + onSendMessage(message) + } + } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + value = inputText, + onValueChange = onInputTextChange, + placeholder = { + Text( + text = "Ask to book flights, hotels, dining...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (onVoiceClick != null) { + IconButton(onClick = onVoiceClick, enabled = !isStreaming) { + Icon( + imageVector = Icons.Outlined.Mic, + contentDescription = "Voice input", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(28.dp), + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions(onSend = { submit() }), + modifier = Modifier.weight(1f), + ) + + FilledIconButton( + onClick = { submit() }, + enabled = inputText.isNotBlank() && !isStreaming, + shape = CircleShape, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.size(52.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = "Send prompt", + modifier = Modifier.size(20.dp), + ) + } + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/ChatMessageItem.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/ChatMessageItem.kt new file mode 100644 index 00000000..e09fbee8 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/ChatMessageItem.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.components + +import androidx.a2ui.model.processor.A2uiSurfaceModel +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.a2ui.A2uiSurface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.jetpacker.feature.booking_assistant.BookingChatMessage + +@Composable +fun ChatMessageItem( + message: BookingChatMessage, + surfaceModel: A2uiSurfaceModel?, + modifier: Modifier = Modifier, +) { + if (message.isUser) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Surface( + shape = + RoundedCornerShape( + topStart = 16.dp, + topEnd = 4.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, + ), + color = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.widthIn(max = 300.dp), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.padding(14.dp), + ) + } + } + } else { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (message.text.isNotBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = + Modifier.size(32.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = "Trip Concierge", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + } + + Card( + shape = + RoundedCornerShape( + topStart = 4.dp, + topEnd = 16.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.weight(1f), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "Concierge", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + + if (surfaceModel != null) { + A2uiSurface( + surfaceModel = surfaceModel, + modifier = + Modifier.fillMaxWidth() + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + ), + ) + } + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/JourneyCategoryCard.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/JourneyCategoryCard.kt new file mode 100644 index 00000000..843e2e46 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/components/JourneyCategoryCard.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.example.jetpacker.feature.booking_assistant.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun JourneyCategoryCard( + title: String, + subtitle: String, + icon: ImageVector, + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + expandedContent: @Composable () -> Unit, +) { + val arrowRotation by animateFloatAsState(if (expanded) 180f else 0f, label = "ArrowRotation") + + Card( + onClick = onToggle, + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.size(48.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + MaterialShapes.Sunny.toShape(), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp), + ) + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = + Modifier.size(28.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = arrowRotation }, + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + expandedContent() + } + } + } +} + +@Composable +fun JourneySubItemRow( + title: String, + subtitle: String, + ctaText: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clip(RoundedCornerShape(50)), + ) { + Text( + text = ctaText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingAssistantUiState.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingAssistantUiState.kt new file mode 100644 index 00000000..2d6cd33f --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingAssistantUiState.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.domain + +import androidx.a2ui.model.processor.A2uiSurfaceModel +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.feature.booking_assistant.BookableJourneyItem +import com.example.jetpacker.feature.booking_assistant.BookingChatMessage + +data class BookingAssistantUiState( + val activeSurfaces: List = emptyList(), + val isStreaming: Boolean = false, + val chatMessages: List = emptyList(), + val flightJourneyItems: List = emptyList(), + val activityJourneyItems: List = emptyList(), + val trip: Trip? = null, +) diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizer.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizer.kt new file mode 100644 index 00000000..09b2730a --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizer.kt @@ -0,0 +1,208 @@ +/* + * 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.booking_assistant.domain + +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.trips.DummyData +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.flow.first +import org.json.JSONObject + +/** + * Handles cleaning, deduplication, and payload construction for booking events. + */ +object BookingEventNormalizer { + + private val SHORT_DATE_FORMAT = + DateTimeFormatter.ofPattern("MMM dd", Locale.US).withZone(ZoneOffset.UTC) + + fun formatEpochShortDate(epochMillis: Long): String { + return runCatching { SHORT_DATE_FORMAT.format(Instant.ofEpochMilli(epochMillis)) } + .getOrDefault("DATE") + } + + fun cleanHotelTitle(event: TimelineEvent): String { + val detailName = DummyData.hotelDetails.find { it.eventId == event.id }?.name?.trim() + if (!detailName.isNullOrEmpty()) return detailName + val stripped = + event.title + .replace(Regex("^(Check-in|Check in)\\s*[:@\\-]\\s*", RegexOption.IGNORE_CASE), "") + .trim() + if (stripped.isNotEmpty() && !stripped.equals(event.title, ignoreCase = true)) { + return stripped + } + return event.location.trim().ifEmpty { event.title } + } + + fun normalizeBookableEvents(events: List): List { + val bookableTypes = + setOf( + EventType.TRANSPORTATION, + EventType.ACCOMMODATION, + EventType.ACTIVITY, + EventType.CULTURE, + EventType.FOOD_AND_DRINK, + ) + val filtered = events.filter { it.type in bookableTypes } + val accommodationEvents = filtered.filter { it.type == EventType.ACCOMMODATION } + val checkins = accommodationEvents.filter { + it.title.contains("Check-in", ignoreCase = true) || + it.title.contains("Check in", ignoreCase = true) + } + val checkouts = accommodationEvents.filter { + it.title.contains("Checkout", ignoreCase = true) || + it.title.contains("Check-out", ignoreCase = true) || + it.title.contains("Check out", ignoreCase = true) + } + val matchedCheckouts = mutableSetOf() + val mergedByCheckinId = mutableMapOf() + + for (checkin in checkins) { + val matchingCheckout = checkouts.find { + it.location.equals(checkin.location, ignoreCase = true) && it.timestamp > checkin.timestamp + } + val cleanTitle = cleanHotelTitle(checkin) + if (matchingCheckout != null) { + matchedCheckouts.add(matchingCheckout) + } + mergedByCheckinId[checkin.id] = + checkin.copy(title = cleanTitle.ifEmpty { checkin.title }) + } + + val result = mutableListOf() + for (event in filtered) { + if (event.type == EventType.ACCOMMODATION) { + val merged = mergedByCheckinId[event.id] + if (merged != null) { + result.add(merged) + } else if (!matchedCheckouts.contains(event)) { + val cleanTitle = cleanHotelTitle(event) + result.add(event.copy(title = cleanTitle.ifEmpty { event.title })) + } + } else { + result.add(event) + } + } + return result + } + + suspend fun buildEventJson( + event: TimelineEvent, + eventDao: EventDao, + tripTitle: String? = null, + tripLocation: String? = null, + titleOverride: String? = null, + typeOverride: String? = null, + ): JSONObject { + val eventJson = JSONObject() + eventJson.put("id", event.id) + eventJson.put("type", typeOverride ?: event.type.name) + eventJson.put("title", titleOverride ?: event.title) + eventJson.put("location", event.location) + eventJson.put("extraInfo", event.extraInfo.orEmpty()) + eventJson.put("description", event.description.orEmpty()) + eventJson.put("timestamp", event.timestamp) + eventJson.put("dateLabel", formatEpochShortDate(event.timestamp)) + if (!tripTitle.isNullOrEmpty()) eventJson.put("tripTitle", tripTitle) + if (!tripLocation.isNullOrEmpty()) eventJson.put("tripLocation", tripLocation) + + when (event.type) { + EventType.TRANSPORTATION -> { + val flightDetail = + runCatching { eventDao.getFlightDetail(event.id).first() }.getOrNull() + ?: DummyData.flightDetails.find { it.eventId == event.id } + if (flightDetail != null) { + val fdJson = JSONObject() + fdJson.put("eventId", flightDetail.eventId) + fdJson.put("airline", flightDetail.airline) + fdJson.put("flightNum", flightDetail.flightNum) + fdJson.put("origin", flightDetail.origin) + fdJson.put("destination", flightDetail.destination) + fdJson.put("gate", flightDetail.gate) + fdJson.put("seat", flightDetail.seat) + fdJson.put("departureTerminal", flightDetail.departureTerminal) + fdJson.put("arrivalTerminal", flightDetail.arrivalTerminal) + fdJson.put("arrivalGate", flightDetail.arrivalGate) + fdJson.put("duration", flightDetail.duration) + fdJson.put("aircraft", flightDetail.aircraft) + fdJson.put("baggageAllowance", flightDetail.baggageAllowance) + eventJson.put("flightDetail", fdJson) + } + } + EventType.ACCOMMODATION -> { + val hotelDetail = + runCatching { eventDao.getHotelDetail(event.id).first() }.getOrNull() + ?: DummyData.hotelDetails.find { it.eventId == event.id } + if (hotelDetail != null) { + val hdJson = JSONObject() + hdJson.put("name", hotelDetail.name) + hdJson.put("address", hotelDetail.address) + hdJson.put("checkInTime", hotelDetail.checkInTime) + hdJson.put("checkOutTime", hotelDetail.checkOutTime) + hdJson.put("checkInDate", formatEpochShortDate(hotelDetail.checkInTime).uppercase()) + hdJson.put("checkOutDate", formatEpochShortDate(hotelDetail.checkOutTime).uppercase()) + val nights = + ((hotelDetail.checkOutTime - hotelDetail.checkInTime) / (24 * 3600 * 1000L)) + .toInt() + .coerceAtLeast(1) + hdJson.put("nights", nights) + hdJson.put("confNumber", hotelDetail.confNumber) + hdJson.put("rating", hotelDetail.rating) + hdJson.put("pricePerNight", hotelDetail.pricePerNight) + hdJson.put("guests", hotelDetail.guests) + eventJson.put("hotelDetail", hdJson) + } + } + EventType.FOOD_AND_DRINK -> { + val diningDetail = + runCatching { eventDao.getDiningDetail(event.id).first() }.getOrNull() + ?: DummyData.diningDetails.find { it.eventId == event.id } + if (diningDetail != null) { + val ddJson = JSONObject() + ddJson.put("restaurantName", diningDetail.restaurantName) + ddJson.put("address", diningDetail.address) + ddJson.put("partySize", diningDetail.partySize) + ddJson.put("rating", diningDetail.rating) + ddJson.put("priceRange", diningDetail.priceRange) + eventJson.put("diningDetail", ddJson) + } + } + EventType.CULTURE, + EventType.ACTIVITY -> { + val museumDetail = + runCatching { eventDao.getMuseumDetail(event.id).first() }.getOrNull() + ?: DummyData.museumDetails.find { it.eventId == event.id } + if (museumDetail != null) { + val mdJson = JSONObject() + mdJson.put("description", museumDetail.description) + mdJson.put("address", museumDetail.address) + mdJson.put("openingHours", museumDetail.openingHours) + mdJson.put("admissionPrice", museumDetail.admissionPrice) + mdJson.put("rating", museumDetail.rating) + eventJson.put("museumDetail", mdJson) + } + } + else -> {} + } + return eventJson + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifier.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifier.kt new file mode 100644 index 00000000..f9795c95 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifier.kt @@ -0,0 +1,186 @@ +/* + * 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.booking_assistant.domain + +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent + +/** + * Classifies conversational inputs and timeline events into booking intents and categories. + */ +object BookingIntentClassifier { + + private val HOTEL_KEYWORDS = + setOf( + "hotel", + "stay", + "room", + "suite", + "check-in", + "checkout", + "check out", + "redwood", + "shelbourne", + "meurice", + "shibuya", + "artemide", + "four seasons", + "grove", + "seasons", + ) + + private val DINING_KEYWORDS = + setOf( + "dinner", + "lunch", + "restaurant", + "table", + "quantum", + "dining", + "ramen", + "ichiran", + "jungsik", + "verne", + "bistro", + "cafe", + ) + + private val ACTIVITY_KEYWORDS = + setOf( + "museum", + "activity", + "ticket", + "pass", + "sfmoma", + "cruise", + "tour", + "louvre", + "teamlab", + "colosseum", + "vatican", + "cliffs", + ) + + private val FLIGHT_KEYWORDS = + setOf( + "flight", + "fly", + "plane", + "airline", + "airport", + "seat", + "boarding", + "outbound", + "return", + "sfo", + "cdg", + "jfk", + "ams", + "nrt", + "hnd", + ) + + fun resolveIntent( + prompt: String, + directMatchEvent: TimelineEvent? = null, + intentOverride: String? = null, + ): String { + if (intentOverride != null) return intentOverride + + directMatchEvent?.type?.let { type -> + return when (type) { + EventType.ACCOMMODATION -> "hotel" + EventType.FOOD_AND_DRINK -> "dining" + EventType.CULTURE, + EventType.ACTIVITY -> "activity" + EventType.TRANSPORTATION -> "flight" + else -> "conversation" + } + } + + val lower = prompt.lowercase() + return when { + HOTEL_KEYWORDS.any { lower.contains(it) } -> "hotel" + DINING_KEYWORDS.any { lower.contains(it) } -> "dining" + ACTIVITY_KEYWORDS.any { lower.contains(it) } -> "activity" + FLIGHT_KEYWORDS.any { lower.contains(it) } -> "flight" + else -> "conversation" + } + } + + fun resolveTargetTitle( + prompt: String, + resolvedIntent: String, + tripEvents: List, + directMatchEvent: TimelineEvent? = null, + targetTitleOverride: String? = null, + ): String { + if (targetTitleOverride != null) return targetTitleOverride + if (directMatchEvent != null) return directMatchEvent.title + + val lower = prompt.lowercase() + return when (resolvedIntent) { + "hotel" -> + tripEvents.firstOrNull { it.type == EventType.ACCOMMODATION }?.title + ?: "The Redwood Grove Hotel" + "dining" -> + (tripEvents.firstOrNull { + it.type == EventType.FOOD_AND_DRINK && it.title.contains("Dinner", ignoreCase = true) + } ?: tripEvents.firstOrNull { it.type == EventType.FOOD_AND_DRINK }) + ?.title ?: "Dinner @ Quantum Bites" + "activity" -> + tripEvents + .firstOrNull { it.type == EventType.CULTURE || it.type == EventType.ACTIVITY } + ?.title ?: "SFMOMA Museum Visit" + "flight" -> { + if (lower.contains("return") || lower.contains("inbound") || lower.contains("back")) { + tripEvents.lastOrNull { it.type == EventType.TRANSPORTATION }?.title ?: "CDG > SFO" + } else { + tripEvents.firstOrNull { it.type == EventType.TRANSPORTATION }?.title ?: "CDG > SFO" + } + } + else -> "" + } + } + + fun getCategory(title: String, tripEvents: List): String { + val event = + tripEvents.find { + it.title.equals(title, ignoreCase = true) || + it.location.equals(title, ignoreCase = true) || + it.id == title + } + event?.type?.let { type -> + return when (type) { + EventType.TRANSPORTATION -> "Flight" + EventType.ACCOMMODATION -> "Hotel" + EventType.ACTIVITY, + EventType.CULTURE -> "Activity" + EventType.FOOD_AND_DRINK -> "Dining" + else -> "Activity" + } + } + + val lower = title.lowercase() + return when { + title.contains(" > ") || lower.contains("flight") -> "Flight" + HOTEL_KEYWORDS.any { lower.contains(it) } -> "Hotel" + DINING_KEYWORDS.any { lower.contains(it) } -> "Dining" + else -> "Activity" + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingDirectiveParserTest.kt b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingDirectiveParserTest.kt new file mode 100644 index 00000000..ab2e8ba1 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingDirectiveParserTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.domain + +import com.example.jetpacker.feature.booking_assistant.BookingAssistantViewModel +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BookingDirectiveParserTest { + + @Test + fun extractA2uiDirectives_singleObjectPayload_returnsSingleItem() { + val singleObject = + """{"version":"v0.9","createSurface":{"surfaceId":"hotel_Flight"}}""" + val directives = BookingAssistantViewModel.extractA2uiDirectives(singleObject) + assertThat(directives).hasSize(1) + assertThat(directives[0]).contains("\"surfaceId\":\"hotel_Flight\"") + } + + @Test + fun extractA2uiDirectives_arrayPayload_unwrapsAllDirectives() { + val arrayPayload = + """[{"version":"v0.9","createSurface":{"surfaceId":"s1"}},{"version":"v0.9","updateComponents":{"surfaceId":"s1"}}]""" + val directives = BookingAssistantViewModel.extractA2uiDirectives(arrayPayload) + assertThat(directives).hasSize(2) + assertThat(directives[0]).contains("\"createSurface\"") + assertThat(directives[1]).contains("\"updateComponents\"") + } + + @Test + fun extractA2uiDirectives_nonA2uiText_returnsEmptyList() { + val plainText = "Hello! How can I help you book your trip today?" + val directives = BookingAssistantViewModel.extractA2uiDirectives(plainText) + assertThat(directives).isEmpty() + } + + @Test + fun extractA2uiDirectives_malformedArray_fallsBackToEmptyList() { + val malformed = """[{"version":"v0.9","createSurface":{"surfaceId":""" + val directives = BookingAssistantViewModel.extractA2uiDirectives(malformed) + assertThat(directives).isEmpty() + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizerTest.kt b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizerTest.kt new file mode 100644 index 00000000..ab922525 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingEventNormalizerTest.kt @@ -0,0 +1,71 @@ +/* + * 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.booking_assistant.domain + +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BookingEventNormalizerTest { + + @Test + fun cleanHotelTitle_stripsCheckinPrefix() { + val event = TimelineEvent( + id = "event-hotel", + tripId = "trip-1", + type = EventType.ACCOMMODATION, + timestamp = 1000L, + title = "Check-in @ The Redwood Grove Hotel", + location = "California", + ) + + val cleaned = BookingEventNormalizer.cleanHotelTitle(event) + assertThat(cleaned).isEqualTo("The Redwood Grove Hotel") + } + + @Test + fun normalizeBookableEvents_filtersOutCheckoutEvents() { + val checkin = TimelineEvent( + id = "event-checkin", + tripId = "trip-1", + type = EventType.ACCOMMODATION, + timestamp = 1000L, + title = "Check-in @ Hotel Shibuya", + location = "Tokyo", + ) + val checkout = TimelineEvent( + id = "event-checkout", + tripId = "trip-1", + type = EventType.ACCOMMODATION, + timestamp = 3000L, + title = "Check-out @ Hotel Shibuya", + location = "Tokyo", + ) + + val normalized = BookingEventNormalizer.normalizeBookableEvents(listOf(checkin, checkout)) + assertThat(normalized).hasSize(1) + assertThat(normalized.first().title).isEqualTo("Hotel Shibuya") + } + + @Test + fun formatEpochShortDate_formatsTimestampAccurately() { + // 1700000000000L is Nov 14, 2023 UTC + val formatted = BookingEventNormalizer.formatEpochShortDate(1700000000000L) + assertThat(formatted).isEqualTo("Nov 14") + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifierTest.kt b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifierTest.kt new file mode 100644 index 00000000..3d2af0e0 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/test/kotlin/com/example/jetpacker/feature/booking_assistant/domain/BookingIntentClassifierTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant.domain + +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BookingIntentClassifierTest { + + @Test + fun resolveIntent_withIntentOverride_returnsOverride() { + val intent = BookingIntentClassifier.resolveIntent( + prompt = "Can you help me with something?", + intentOverride = "flight", + ) + assertThat(intent).isEqualTo("flight") + } + + @Test + fun resolveIntent_withDirectMatchEvent_returnsEventTypeIntent() { + val event = TimelineEvent( + id = "event-1", + tripId = "trip-1", + type = EventType.TRANSPORTATION, + timestamp = 1000L, + title = "Flight to Tokyo", + location = "NRT", + ) + val intent = BookingIntentClassifier.resolveIntent( + prompt = "random text", + directMatchEvent = event, + ) + assertThat(intent).isEqualTo("flight") + } + + @Test + fun resolveIntent_withKeywords_identifiesCorrectIntents() { + assertThat(BookingIntentClassifier.resolveIntent("Book a hotel in Shibuya")) + .isEqualTo("hotel") + assertThat(BookingIntentClassifier.resolveIntent("Reserve a table for dinner")) + .isEqualTo("dining") + assertThat(BookingIntentClassifier.resolveIntent("Get tickets for museum tour")) + .isEqualTo("activity") + assertThat(BookingIntentClassifier.resolveIntent("Check airline flights from SFO")) + .isEqualTo("flight") + assertThat(BookingIntentClassifier.resolveIntent("Hello, how are you?")) + .isEqualTo("conversation") + } + + @Test + fun resolveTargetTitle_findsCorrectEventByIntent() { + val events = listOf( + TimelineEvent( + id = "f-1", + tripId = "trip-1", + type = EventType.TRANSPORTATION, + timestamp = 1000L, + title = "SFO > HND", + location = "Tokyo", + ), + TimelineEvent( + id = "h-1", + tripId = "trip-1", + type = EventType.ACCOMMODATION, + timestamp = 2000L, + title = "Shibuya Grand Hotel", + location = "Tokyo", + ), + TimelineEvent( + id = "d-1", + tripId = "trip-1", + type = EventType.FOOD_AND_DRINK, + timestamp = 3000L, + title = "Dinner @ Tokyo Ramen", + location = "Tokyo", + ), + ) + + val hotelTitle = BookingIntentClassifier.resolveTargetTitle( + prompt = "I need a hotel room", + resolvedIntent = "hotel", + tripEvents = events, + ) + assertThat(hotelTitle).isEqualTo("Shibuya Grand Hotel") + + val flightTitle = BookingIntentClassifier.resolveTargetTitle( + prompt = "Book flight", + resolvedIntent = "flight", + tripEvents = events, + ) + assertThat(flightTitle).isEqualTo("SFO > HND") + } + + @Test + fun getCategory_mapsEventTypesAndKeywordsAccurately() { + val events = listOf( + TimelineEvent( + id = "event-1", + tripId = "trip-1", + type = EventType.ACTIVITY, + timestamp = 1000L, + title = "Sunset Cruise", + location = "Bay", + ) + ) + + assertThat(BookingIntentClassifier.getCategory("Sunset Cruise", events)).isEqualTo("Activity") + assertThat(BookingIntentClassifier.getCategory("SFO > CDG", emptyList())).isEqualTo("Flight") + assertThat(BookingIntentClassifier.getCategory("Four Seasons", emptyList())).isEqualTo("Hotel") + assertThat(BookingIntentClassifier.getCategory("Bistro Lunch", emptyList())).isEqualTo("Dining") + } +} diff --git a/jetpacker/android/feature/trip/build.gradle.kts b/jetpacker/android/feature/trip/build.gradle.kts index 02b7b0d6..0d8a2e92 100644 --- a/jetpacker/android/feature/trip/build.gradle.kts +++ b/jetpacker/android/feature/trip/build.gradle.kts @@ -24,7 +24,12 @@ plugins { android { namespace = "com.example.jetpacker.feature.trip" - compileSdk = libs.versions.compileSdk.get().toInt() + compileSdk { + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml index 2a482182..a366c8bd 100644 --- a/jetpacker/android/gradle/libs.versions.toml +++ b/jetpacker/android/gradle/libs.versions.toml @@ -9,7 +9,7 @@ cameraLifecycle = "1.5.0" cameraView = "1.5.0" coilCompose = "2.7.0" compileSdk = "37" -compileSdkMinor = "0" +compileSdkMinor = "1" composeBom = "2026.03.00" composeScreenshot = "0.0.1-alpha15" converterMoshi = "2.12.0" diff --git a/jetpacker/server/agents/__init__.py b/jetpacker/server/agents/__init__.py new file mode 100644 index 00000000..cdd31ea6 --- /dev/null +++ b/jetpacker/server/agents/__init__.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from agents.base import BaseSimulationAgent, SessionState, sessions +from agents.flight import FlightSimulationAgent, SingleFlightSimulationAgent +from agents.hotel import HotelSimulationAgent, SingleHotelSimulationAgent +from agents.activity import ( + MuseumSimulationAgent, + RestaurantSimulationAgent, + SingleMuseumSimulationAgent, + SingleRestaurantSimulationAgent, +) +from agents.orchestrator import ItineraryOrchestratorAgent + +__all__ = [ + "BaseSimulationAgent", + "SessionState", + "sessions", + "FlightSimulationAgent", + "SingleFlightSimulationAgent", + "HotelSimulationAgent", + "SingleHotelSimulationAgent", + "MuseumSimulationAgent", + "RestaurantSimulationAgent", + "SingleMuseumSimulationAgent", + "SingleRestaurantSimulationAgent", + "ItineraryOrchestratorAgent", +] diff --git a/jetpacker/server/agents/activity.py b/jetpacker/server/agents/activity.py new file mode 100644 index 00000000..aebdf115 --- /dev/null +++ b/jetpacker/server/agents/activity.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, AsyncGenerator + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.events.event import Event +from google.genai import types + +from agents.base import ( + BOOKING_ASSISTANT_CATALOG_ID, + BaseSimulationAgent, + _get_genai_client, + sessions, +) +from schemas import ActivityOrDiningProposal + +class SingleMuseumSimulationAgent(BaseSimulationAgent): + name: str = "single_museum" + item_title: str = "Museum" + item_data: dict[str, Any] = {} + full_itinerary: list[dict[str, Any]] = [] + + async def _generate_activity_proposal(self) -> dict[str, Any]: + title = self.item_title + date_label = self.item_data.get("dateLabel") or "" + fallback = { + "prompt": ( + "Select your preferred timed-entry window and pass tier for" + f" {title}{f' ({date_label})' if date_label else ''}:" + ), + "options": [ + "10:00 AM • VIP Skip-the-Line + Audio Guide", + "01:30 PM • Express Afternoon Entry", + "04:00 PM • Golden Hour Curator Tour", + ], + "holdConfirmationPrompt": ( + f"Holding 2 passes for {title}. Instant mobile QR entry included." + ), + "confirmedDescription": "Mobile QR Passes added to your trip wallet.", + } + try: + client = _get_genai_client() + prompt = ( + "You are a travel concierge generating an A2UI v0.9 activity/museum" + " ticket booking payload.\n" + f"Activity event item: {json.dumps(self.item_data)}\n" + f"Full trip itinerary context: {json.dumps(self.full_itinerary)}\n" + "Return an ActivityOrDiningProposal JSON tailored to this specific" + " museum, tour, or activity:\n" + "- `prompt`: 1 concise sentence inviting the user to choose a" + " timed-entry window or tour tier (never mention 'AI' or 'A2UI').\n" + "- `options`: Exactly 3 realistic time-slot + experience tier strings" + " formatted like '10:00 AM • '.\n" + "- `holdConfirmationPrompt`: 1 concise sentence summarizing what" + " is held before final confirmation.\n" + "- `confirmedDescription`: Short confirmation note for the wallet." + ) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ActivityOrDiningProposal, + temperature=0.3, + ), + ) + if response.text: + parsed = json.loads(response.text) + if parsed.get("options"): + print( + f"INFO: Gemini generated activity options for {title}:" + f" {parsed['options']}" + ) + return parsed + except Exception as e: # pylint: disable=broad-exception-caught + print(f"WARN: Gemini activity fallback used for {title}: {e}") + return fallback + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + proposal = await self._generate_activity_proposal() + options = proposal["options"] + + # Step 1: Select Timed Entry Slot + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": title, + "catalogId": BOOKING_ASSISTANT_CATALOG_ID, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "InteractiveOptionPicker", + "category": "Activity", + "title": title, + "status": "SELECT ENTRY SLOT", + "prompt": proposal["prompt"], + "options": options, + "selectedOption": options[0], + "confirmBtnText": "Continue to Pass Confirmation", + "action": { + "event": { + "name": "confirm_option", + "context": { + "agentId": title, + "selectionType": "tickets", + }, + } + }, + }], + }, + }, + ], + ) + + pause_key = title + if pause_key not in session.pause_events: + session.pause_events[pause_key] = asyncio.Event() + else: + session.pause_events[pause_key].clear() + + await session.pause_events[pause_key].wait() + resp = session.results.get(title) + chosen_slot = ( + ( + resp.get("selectedOption") + or resp.get("people") + or resp.get("tickets") + or resp.get("time") + or options[0] + ) + if isinstance(resp, dict) + else str(resp or options[0]) + ) + + # Step 2: Final Confirmation Card + yield self._create_a2ui_event( + ctx, + author=title, + messages=[{ + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "BookingConfirmation", + "category": "Activity", + "title": title, + "status": "FINAL REVIEW", + "prompt": ( + f"Holding passes for '{chosen_slot}'." + f" {proposal['holdConfirmationPrompt']}" + ), + "confirmBtnText": "Issue Mobile Passes", + "action": { + "event": { + "name": "confirm_booking", + "context": { + "agentId": title, + "selectionType": "confirm", + }, + } + }, + }], + }, + }], + ) + + session.pause_events[pause_key].clear() + await session.pause_events[pause_key].wait() + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[{ + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "BookingStatus", + "category": "Activity", + "title": title, + "description": ( + f"Confirmed: {chosen_slot} •" + f" {proposal['confirmedDescription']}" + ), + "status": "Confirmed", + }], + }, + }], + ) + await asyncio.sleep(0.4) + yield self._create_end_event(ctx, author=title) + + +class MuseumSimulationAgent(BaseSimulationAgent): + name: str = "museum" + items: list[dict[str, Any]] = [] + full_itinerary: list[dict[str, Any]] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleMuseumSimulationAgent( + name=f"single_museum_{i}", + item_title=item.get("title", "Museum"), + item_data=item, + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent( + name="museums_parallel", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + + yield self._create_end_event(ctx, author=self.name) + + +class SingleRestaurantSimulationAgent(BaseSimulationAgent): + name: str = "single_restaurant" + item_title: str = "Restaurant" + item_data: dict[str, Any] = {} + full_itinerary: list[dict[str, Any]] = [] + + async def _generate_dining_proposal(self) -> dict[str, Any]: + title = self.item_title + dining_detail = self.item_data.get("diningDetail") or {} + party = dining_detail.get("partySize") or 2 + date_label = self.item_data.get("dateLabel") or "" + fallback = { + "prompt": ( + f"Select your party size and seating zone for {title}" + f"{f' ({date_label})' if date_label else ''}:" + ), + "options": [ + f"{party} Guests • 7:00 PM Chef's Counter", + f"{party} Guests • 7:45 PM Main Dining Room", + f"{party} Guests • 8:15 PM Patio Terrace", + f"{party + 2} Guests • 8:30 PM Private Booth", + ], + "holdConfirmationPrompt": ( + "Complimentary welcome aperitif included with your reservation." + ), + "confirmedDescription": "Reservation confirmed and added to itinerary.", + } + try: + client = _get_genai_client() + prompt = ( + "You are a restaurant concierge generating an A2UI v0.9 dining" + " reservation payload.\n" + f"Dining event item: {json.dumps(self.item_data)}\n" + f"Full trip itinerary context: {json.dumps(self.full_itinerary)}\n" + "Return an ActivityOrDiningProposal JSON tailored to this specific" + " restaurant and city:\n" + "- `prompt`: 1 concise sentence inviting the user to choose their" + " seating zone and time (never mention 'AI' or 'A2UI').\n" + "- `options`: Exactly 4 realistic seating options formatted like" + " '2 Guests • 7:30 PM '.\n" + "- `holdConfirmationPrompt`: 1 concise sentence describing a" + " signature welcome perk or tasting note for this restaurant.\n" + "- `confirmedDescription`: Short confirmation code and summary." + ) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ActivityOrDiningProposal, + temperature=0.3, + ), + ) + if response.text: + parsed = json.loads(response.text) + if parsed.get("options"): + print( + f"INFO: Gemini generated dining options for {title}:" + f" {parsed['options']}" + ) + return parsed + except Exception as e: # pylint: disable=broad-exception-caught + print(f"WARN: Gemini dining fallback used for {title}: {e}") + return fallback + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + proposal = await self._generate_dining_proposal() + options = proposal["options"] + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": title, + "catalogId": BOOKING_ASSISTANT_CATALOG_ID, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "InteractiveOptionPicker", + "category": "Dining", + "title": title, + "status": "SELECT TABLE & TIME", + "prompt": proposal["prompt"], + "options": options, + "selectedOption": options[0], + "confirmBtnText": "Hold Table & Review", + "action": { + "event": { + "name": "confirm_option", + "context": { + "agentId": title, + "selectionType": "tickets", + }, + } + }, + }], + }, + }, + ], + ) + + if title not in session.pause_events: + session.pause_events[title] = asyncio.Event() + else: + session.pause_events[title].clear() + + await session.pause_events[title].wait() + + resp = session.results.get(title) + if isinstance(resp, dict): + table_choice = ( + resp.get("selectedOption") + or resp.get("people") + or resp.get("tickets") + or options[0] + ) + else: + table_choice = str(resp or options[0]) + + # Step 2: Confirm dietary/tasting note + yield self._create_a2ui_event( + ctx, + author=title, + messages=[{ + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "BookingConfirmation", + "category": "Dining", + "title": title, + "status": "CONFIRM RESERVATION", + "prompt": ( + f"Table held for {table_choice}." + f" {proposal['holdConfirmationPrompt']}" + ), + "confirmBtnText": "Confirm Table Reservation", + "action": { + "event": { + "name": "confirm_booking", + "context": { + "agentId": title, + "selectionType": "confirm", + }, + } + }, + }], + }, + }], + ) + + session.pause_events[title].clear() + await session.pause_events[title].wait() + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[{ + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "BookingStatus", + "category": "Dining", + "title": title, + "description": ( + f"Confirmed: {table_choice} •" + f" {proposal['confirmedDescription']}" + ), + "status": "Confirmed", + }], + }, + }], + ) + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(0.4) + + +class RestaurantSimulationAgent(BaseSimulationAgent): + name: str = "restaurant" + items: list[dict[str, Any]] = [] + full_itinerary: list[dict[str, Any]] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleRestaurantSimulationAgent( + name=f"single_restaurant_{i}", + item_title=item.get("title", "Restaurant"), + item_data=item, + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent( + name="restaurants_parallel", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + diff --git a/jetpacker/server/agents/base.py b/jetpacker/server/agents/base.py new file mode 100644 index 00000000..5d4ea076 --- /dev/null +++ b/jetpacker/server/agents/base.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio +import json +import os +import re +from typing import Any, AsyncGenerator + +from google import genai +from google.adk.agents.base_agent import BaseAgent +from google.adk.agents.invocation_context import InvocationContext +from google.adk.events.event import Event +from google.adk.events.event_actions import EventActions +from google.genai import types + +BOOKING_ASSISTANT_CATALOG_ID = ( + "https://example.com/catalogs/booking_assistant/v1/catalog.json" +) +PROGRESSIVE_SKELETON_DELAY_SEC = 1.1 +LIVE_SEAT_PATCH_DELAY_SEC = 3.5 + + +def _get_genai_client() -> genai.Client: + """Initializes a Gemini client using Vertex AI or API key.""" + project = os.environ.get("GOOGLE_CLOUD_PROJECT", "your-gcp-project") + location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") + if os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("1", "true"): + return genai.Client(vertexai=True, project=project, location=location) + api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") + if api_key: + return genai.Client(api_key=api_key) + return genai.Client(vertexai=True, project=project, location=location) + + +class SessionState: + + def __init__(self) -> None: + self.pause_events: dict[str, asyncio.Event] = {} + self.results: dict[str, Any] = {} + self.completed_steps: dict[str, int] = {} + + +sessions: dict[str, SessionState] = {} + +_KNOWN_AIRPORTS: dict[str, tuple[str, str]] = { + "ams": ("AMS", "Amsterdam"), + "amsterdam": ("AMS", "Amsterdam"), + "schiphol": ("AMS", "Amsterdam"), + "sfo": ("SFO", "San Francisco"), + "san francisco": ("SFO", "San Francisco"), + "cdg": ("CDG", "Paris"), + "paris": ("CDG", "Paris"), + "nrt": ("NRT", "Tokyo"), + "hnd": ("HND", "Tokyo"), + "tokyo": ("NRT", "Tokyo"), + "narita": ("NRT", "Tokyo"), + "haneda": ("HND", "Tokyo"), + "dub": ("DUB", "Dublin"), + "dublin": ("DUB", "Dublin"), + "fco": ("FCO", "Rome"), + "rome": ("FCO", "Rome"), + "fiumicino": ("FCO", "Rome"), + "icn": ("ICN", "Seoul"), + "seoul": ("ICN", "Seoul"), + "incheon": ("ICN", "Seoul"), + "lhr": ("LHR", "London"), + "london": ("LHR", "London"), + "jfk": ("JFK", "New York"), + "new york": ("JFK", "New York"), + "lax": ("LAX", "Los Angeles"), + "los angeles": ("LAX", "Los Angeles"), + "bcn": ("BCN", "Barcelona"), + "barcelona": ("BCN", "Barcelona"), + "sin": ("SIN", "Singapore"), + "singapore": ("SIN", "Singapore"), +} + + +def _resolve_airport( + raw_text: str, default_code: str, default_city: str +) -> tuple[str, str]: + """Extracts an (IATA code, City Name) tuple from free-form text.""" + if not raw_text: + return default_code, default_city + + lower = raw_text.lower() + for key, (code, city) in _KNOWN_AIRPORTS.items(): + if re.search(rf"\b{re.escape(key)}\b", lower): + return code, city + + iata_match = re.search(r"\b([A-Z]{3})\b", raw_text) + if iata_match: + code = iata_match.group(1) + if code not in ("THE", "AND", "FOR", "VIA", "NON", "ADS"): + city_clean = re.sub( + r"^(?:return\s+)?flight\s+(?:to|from)\s+", + "", + re.sub(r"\([A-Z]{3}\)", "", raw_text), + flags=re.IGNORECASE, + ).strip(" -•\t\r\n") + return code, (city_clean or code) + + cleaned = re.sub( + r"^(return\s+)?flight\s+(to|from)\s+", "", raw_text, flags=re.IGNORECASE + ).strip() + if cleaned: + code = "".join(ch for ch in cleaned.upper() if ch.isalpha())[:3].ljust( + 3, "X" + ) + return code, cleaned + + return default_code, default_city + + + +class BaseSimulationAgent(BaseAgent): + + async def _generate_text(self, prompt: str) -> str: + try: + client = _get_genai_client() + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + ) + text = response.text or "" + return text.strip() + except Exception as e: # pylint: disable=broad-exception-caught + print(f"WARN: _generate_text fallback triggered: {e}") + return "" + + def _create_text_event( + self, ctx: InvocationContext, text: str, author: str | None = None + ) -> Event: + return Event( + invocation_id=ctx.invocation_id, + author=author or self.name, + content=types.Content(parts=[types.Part(text=text)]), + branch=ctx.branch, + ) + + def _create_a2ui_event( + self, + ctx: InvocationContext, + author: str, + messages: list[dict[str, Any]], + ) -> Event: + return self._create_text_event( + ctx, + json.dumps(messages), + author=author, + ) + + def _create_end_event( + self, ctx: InvocationContext, author: str | None = None + ) -> Event: + return Event( + invocation_id=ctx.invocation_id, + author=author or self.name, + actions=EventActions(end_of_agent=True), + content=types.Content(parts=[]), + branch=ctx.branch, + ) + + def _create_options_event( + self, + ctx: InvocationContext, + title: str, + options: list[str], + message: str, + ui_type: str = "seat_map", + ) -> Event: + return self._create_text_event( + ctx, + json.dumps({ + "ui": ui_type, + "options": options, + "message": message, + }), + author=title, + ) + diff --git a/jetpacker/server/agents/flight.py b/jetpacker/server/agents/flight.py new file mode 100644 index 00000000..644e513c --- /dev/null +++ b/jetpacker/server/agents/flight.py @@ -0,0 +1,929 @@ +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, AsyncGenerator + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.events.event import Event +from google.genai import types + +from agents.base import ( + BOOKING_ASSISTANT_CATALOG_ID, + LIVE_SEAT_PATCH_DELAY_SEC, + PROGRESSIVE_SKELETON_DELAY_SEC, + BaseSimulationAgent, + _get_genai_client, + _resolve_airport, + sessions, +) +from schemas import FlightConfigProposal, FlightOptionSchema + +def _build_fallback_flight_config( + item: dict[str, Any], full_itinerary: list[dict[str, Any]] +) -> dict[str, Any]: + """Builds a deterministic, route-accurate flight configuration for any trip.""" + title = item.get("title", "Flight") + location = item.get("location", "") + extra_info = item.get("extraInfo", "") + flight_detail = item.get("flightDetail") or {} + + fd_origin = flight_detail.get("origin", "") + fd_dest = flight_detail.get("destination", "") + fd_airline = flight_detail.get("airline", "") + fd_flight_num = flight_detail.get("flightNum", "") + fd_aircraft = flight_detail.get("aircraft", "") + fd_duration = flight_detail.get("duration", "") + fd_gate = flight_detail.get("gate", "") + fd_terminal = flight_detail.get("departureTerminal", "") + + is_return = "return" in title.lower() or "home" in title.lower() + + # Infer origin and destination from flightDetail, location, or title + if fd_origin and fd_dest: + origin_code, origin_city = _resolve_airport(fd_origin, "AMS", fd_origin) + dest_code, dest_city = _resolve_airport(fd_dest, "SFO", fd_dest) + elif "->" in location or "→" in location or " to " in location.lower(): + parts = re.split(r"->|→|\bto\b", location, maxsplit=1, flags=re.IGNORECASE) + origin_code, origin_city = _resolve_airport( + parts[0].strip(), "AMS", "Amsterdam" + ) + dest_code, dest_city = _resolve_airport( + parts[1].strip() if len(parts) > 1 else title, "SFO", "San Francisco" + ) + else: + target_code, target_city = _resolve_airport( + f"{title} {location}", "SFO", "San Francisco" + ) + if is_return and target_code == "AMS": + # Find the outbound destination from earlier in the itinerary + outbound_city = "San Francisco" + outbound_code = "SFO" + for other in full_itinerary: + if other.get("title") != title: + c_code, c_city = _resolve_airport( + f"{other.get('title', '')} {other.get('location', '')}", "", "" + ) + if c_code and c_code != "AMS": + outbound_code, outbound_city = c_code, c_city + break + origin_code, origin_city = outbound_code, outbound_city + dest_code, dest_city = "AMS", "Amsterdam" + else: + origin_code, origin_city = "AMS", "Amsterdam" + dest_code, dest_city = target_code, target_city + if origin_code == dest_code: + origin_code, origin_city = "SFO", "San Francisco" + + # Parse airline & flight number from extraInfo if not in flightDetail + airline = fd_airline + flight_num = fd_flight_num + if not flight_num and extra_info: + fn_match = re.search(r"\b([A-Z]{2}\s?\d{2,4})\b", extra_info) + if fn_match: + flight_num = fn_match.group(1) + + route_defaults: dict[str, tuple[str, str, str, str, int]] = { + "SFO": ("KLM Royal Dutch", "KL 605", "Airbus A350-900", "11h 15m", 640), + "NRT": ( + "ANA All Nippon", + "NH 212", + "Boeing 787-9 Dreamliner", + "11h 45m", + 780, + ), + "HND": ("ANA All Nippon", "NH 204", "Boeing 777-300ER", "11h 35m", 790), + "CDG": ("Air France", "AF 1641", "Airbus A220-300", "1h 25m", 185), + "DUB": ("Aer Lingus", "EI 605", "Airbus A320neo", "1h 40m", 165), + "FCO": ("ITA Airways", "AZ 109", "Airbus A321neo", "2h 15m", 220), + "ICN": ( + "Korean Air", + "KE 926", + "Boeing 787-9 Dreamliner", + "11h 20m", + 740, + ), + } + lookup_key = dest_code if dest_code != "AMS" else origin_code + def_airline, def_fn, def_aircraft, def_dur, def_fare = route_defaults.get( + lookup_key, + ("KLM Royal Dutch", "KL 601", "Airbus A350-900", "3h 30m", 320), + ) + + airline = airline or def_airline + flight_num = flight_num or def_fn + aircraft = fd_aircraft or def_aircraft + duration = fd_duration or def_dur + base_fare = def_fare + gate = fd_gate or "D42" + terminal = fd_terminal or "T2" + + prefix = "".join(ch for ch in flight_num if ch.isalpha()) or "KL" + num_str = "".join(ch for ch in flight_num if ch.isdigit()) or "605" + base_num = int(num_str) + + options = [ + { + "id": "opt_1", + "flightNumber": f"{prefix} {base_num}", + "departureTime": "09:50 AM", + "arrivalTime": "12:05 PM", + "duration": duration, + "fare": base_fare, + "badge": "TOP PICK • NON-STOP", + "aircraft": aircraft, + }, + { + "id": "opt_2", + "flightNumber": f"{prefix} {base_num + 4}", + "departureTime": "01:25 PM", + "arrivalTime": "03:40 PM", + "duration": duration, + "fare": base_fare + 55, + "badge": "AFTERNOON DIRECT", + "aircraft": aircraft, + }, + { + "id": "opt_3", + "flightNumber": f"{prefix} {base_num + 12}", + "departureTime": "06:15 PM", + "arrivalTime": "08:30 PM", + "duration": duration, + "fare": max(120, base_fare - 30), + "badge": "EVENING VALUE", + "aircraft": aircraft, + }, + ] + + return { + "originCode": origin_code, + "originCity": origin_city, + "destCode": dest_code, + "destCity": dest_city, + "airline": airline, + "flightNumber": flight_num, + "aircraftName": aircraft, + "duration": duration, + "baseFare": base_fare, + "gate": gate, + "terminal": terminal, + "boardingGroup": "ZONE 2", + "rationale": ( + f"Optimal nonstop {origin_code} → {dest_code} schedule matched to" + f" your {dest_city} itinerary." + ), + "occupiedSeats": ["1B", "1C", "2B", "3C", "4A", "4D"], + "contestedSeat": "2C", + "options": options, + } + + + +class SingleFlightSimulationAgent(BaseSimulationAgent): + name: str = "single_flight" + item_title: str = "Flight" + item_data: dict[str, Any] = {} + full_itinerary: list[dict[str, Any]] = [] + + async def _generate_flight_config( + self, fallback: dict[str, Any] + ) -> dict[str, Any]: + try: + client = _get_genai_client() + prompt = ( + "You are a travel concierge and airline inventory engine generating" + " an A2UI v0.9 flight configurator payload.\n" + f"Flight event item: {json.dumps(self.item_data)}\n" + f"Full trip itinerary context: {json.dumps(self.full_itinerary)}\n" + f"Baseline route defaults: {json.dumps(fallback)}\n" + "Return a realistic FlightConfigProposal JSON matching the exact" + " route, airline, and trip dates/activities.\n" + "- `rationale`: 1 concise sentence explaining how the recommended" + " departure aligns with the traveler's trip schedule (never mention" + " 'AI' or 'A2UI').\n" + "- `options`: Provide 3 distinct flight options on this route with" + " realistic flight numbers, times, aircraft, fares, and short badges" + " (e.g. 'BEST SCHEDULE', 'MORNING EXPRESS', 'EVENING VALUE' — never" + " mention 'AI' or 'A2UI').\n" + "- Keep `occupiedSeats` as a subset of ['1B', '1C', '2B', '3C', '4A'," + " '4D'] (never mark '1A' or '3A' occupied so the user can pick them)" + " and set `contestedSeat` to '2C' (not in occupiedSeats)." + ) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=FlightConfigProposal, + temperature=0.3, + ), + ) + if response.text: + parsed = json.loads(response.text) + occ = [ + s + for s in parsed.get("occupiedSeats", fallback["occupiedSeats"]) + if s not in ("1A", "3A", "2C") + ] + parsed["occupiedSeats"] = occ or ["1B", "1C", "2B", "3C", "4D"] + parsed["contestedSeat"] = "2C" + if not parsed.get("options"): + parsed["options"] = fallback["options"] + print( + f"INFO: Gemini generated flight config for {self.item_title}:" + f" {parsed.get('originCode')} -> {parsed.get('destCode')} (" + f"{len(parsed.get('options', []))} options)" + ) + return parsed + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"WARN: Gemini flight config fallback used for {self.item_title}: {e}" + ) + return fallback + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + baseline = _build_fallback_flight_config( + self.item_data, self.full_itinerary + ) + base_first_opt = baseline["options"][0] + + # ========================================================================= + # ACT 1: Progressive Streaming & Partial Readiness (FlightConfiguratorShell) + # ========================================================================= + if session.completed_steps.get(title, 0) < 1: + initial_data_model = { + "flight": { + "originCode": baseline["originCode"], + "originCity": baseline["originCity"], + "destCode": baseline["destCode"], + "destCity": baseline["destCity"], + "airline": ( + f"{baseline['airline']} • {base_first_opt['flightNumber']}" + ), + "flightNumber": base_first_opt["flightNumber"], + "departureTime": base_first_opt["departureTime"], + "arrivalTime": base_first_opt["arrivalTime"], + "duration": base_first_opt["duration"], + "aircraftName": base_first_opt.get( + "aircraft", baseline["aircraftName"] + ), + "baseFare": base_first_opt["fare"], + "gate": baseline["gate"], + "terminal": baseline["terminal"], + "boardingGroup": baseline["boardingGroup"], + "statusBadge": "SCANNING LIVE FARES", + "aiReason": baseline["rationale"], + "options": baseline["options"], + "optionStrings": [], + }, + "cabin": { + "occupiedSeats": baseline["occupiedSeats"], + "livePatchedSeat": "", + "livePatchMessage": "", + }, + "selection": { + "flightId": base_first_opt["flightNumber"], + "departureTime": ( + f"{base_first_opt['departureTime']} –" + f" {base_first_opt['arrivalTime']}" + ), + "seat": "1A", + "seatExtraPrice": 85, + "priorityBoarding": False, + "extraBag": False, + }, + } + + # 1a. Emit createSurface (sendDataModel=True) + initial data model + + # FlightConfiguratorShell referencing unresolved child 'flight_selector' + # so the skeleton shimmer renders while Gemini generates options. + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": title, + "catalogId": BOOKING_ASSISTANT_CATALOG_ID, + "sendDataModel": True, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/", + "value": initial_data_model, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "FlightConfiguratorShell", + "airline": {"path": "/flight/airline"}, + "originCode": {"path": "/flight/originCode"}, + "originCity": {"path": "/flight/originCity"}, + "destCode": {"path": "/flight/destCode"}, + "destCity": {"path": "/flight/destCity"}, + "statusBadge": {"path": "/flight/statusBadge"}, + "bodySlot": "flight_selector", + }], + }, + }, + ], + ) + + # Generate live flight options & rationale via Gemini 2.5 Flash while shimmer shows + config = await self._generate_flight_config(baseline) + options = config["options"] + first_opt = options[0] + option_strings = [ + f"{opt['flightNumber']}|{opt['departureTime']} –" + f" {opt['arrivalTime']}|{opt.get('aircraft', config['aircraftName'])}" + f" • {opt['duration']}|{opt['fare']}|{opt['badge']}" + for opt in options + ] + ai_reason = config.get("rationale") or baseline["rationale"] + + # 1b. Stream the Gemini-generated data model & resolved 'flight_selector' + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight", + "value": { + "originCode": config["originCode"], + "originCity": config["originCity"], + "destCode": config["destCode"], + "destCity": config["destCity"], + "airline": ( + f"{config['airline']} •" + f" {first_opt['flightNumber']}" + ), + "flightNumber": first_opt["flightNumber"], + "departureTime": first_opt["departureTime"], + "arrivalTime": first_opt["arrivalTime"], + "duration": first_opt["duration"], + "aircraftName": first_opt.get( + "aircraft", config["aircraftName"] + ), + "baseFare": first_opt["fare"], + "gate": config["gate"], + "terminal": config["terminal"], + "boardingGroup": config["boardingGroup"], + "statusBadge": "OPTIONS READY", + "aiReason": ai_reason, + "options": options, + "optionStrings": option_strings, + }, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/flightId", + "value": first_opt["flightNumber"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/departureTime", + "value": ( + f"{first_opt['departureTime']} –" + f" {first_opt['arrivalTime']}" + ), + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "flight_selector", + "component": "FlightOptionSelector", + "aiReason": {"path": "/flight/aiReason"}, + "options": {"path": "/flight/optionStrings"}, + "selectedFlightId": {"path": "/selection/flightId"}, + "selectedFlightTime": { + "path": "/selection/departureTime" + }, + "baseFare": {"path": "/flight/baseFare"}, + "confirmBtnText": "Continue to Cabin & Seats", + "action": { + "event": { + "name": "select_flight", + "context": { + "agentId": title, + "selectionType": "time", + }, + } + }, + }], + }, + }, + ], + ) + + if title not in session.pause_events: + session.pause_events[title] = asyncio.Event() + else: + session.pause_events[title].clear() + + await session.pause_events[title].wait() + session.completed_steps[title] = 1 + else: + config = await self._generate_flight_config(baseline) + options = config["options"] + first_opt = options[0] + + # Resolve which flight option the user picked in Act 1 + act1_resp = session.results.get(title) + chosen_opt = first_opt + if isinstance(act1_resp, dict): + sel_id = act1_resp.get("selectedFlightId") + sel_time = act1_resp.get("time", "") + for opt in options: + if ( + (sel_id and opt["flightNumber"] == sel_id) + or (sel_id and opt["id"] == sel_id) + or (sel_time and opt["flightNumber"] in str(sel_time)) + or (sel_time and opt["departureTime"] in str(sel_time)) + ): + chosen_opt = opt + break + elif isinstance(act1_resp, str): + for opt in options: + if ( + opt["flightNumber"] in act1_resp + or opt["departureTime"] in act1_resp + ): + chosen_opt = opt + break + + # ========================================================================= + # ACT 2: Hybrid Catalogs + Custom Compose Canvas Fuselage + Reactive Binding + # ========================================================================= + if session.completed_steps.get(title, 0) < 2: + # 2a. First update FlightConfiguratorShell to point to unresolved + # 'cabin_experiential_col' and 'fare_footer' so multi-slot shimmer renders + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/airline", + "value": ( + f"{config['airline']} • {chosen_opt['flightNumber']}" + ), + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/flightNumber", + "value": chosen_opt["flightNumber"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/departureTime", + "value": chosen_opt["departureTime"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/arrivalTime", + "value": chosen_opt["arrivalTime"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/baseFare", + "value": chosen_opt["fare"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/statusBadge", + "value": "LOADING CABIN...", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "FlightConfiguratorShell", + "airline": {"path": "/flight/airline"}, + "originCode": {"path": "/flight/originCode"}, + "originCity": {"path": "/flight/originCity"}, + "destCode": {"path": "/flight/destCode"}, + "destCity": {"path": "/flight/destCity"}, + "statusBadge": {"path": "/flight/statusBadge"}, + "bodySlot": "cabin_experiential_col", + "footerSlot": "fare_footer", + }], + }, + }, + ], + ) + + await asyncio.sleep(PROGRESSIVE_SKELETON_DELAY_SEC * 0.75) + + # 2b. Stream the Hybrid Catalog components: + # - Standard M3 Column ('cabin_experiential_col') + # - Custom Compose Canvas Fuselage ('AircraftCabinSeatMap') + # - Custom 'FlightAddonsCard' wrapping Standard M3 CheckBox children + # - Custom 'LiveFareSummaryFooter' bound to the same data model paths + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/statusBadge", + "value": "CABIN READY", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [ + { + "id": "cabin_experiential_col", + "component": "Column", + "children": ["seat_map_canvas", "addons_card"], + }, + { + "id": "seat_map_canvas", + "component": "AircraftCabinSeatMap", + "aircraft": {"path": "/flight/aircraftName"}, + "occupiedSeats": {"path": "/cabin/occupiedSeats"}, + "liveAlert": {"path": "/cabin/livePatchMessage"}, + "selectedSeat": {"path": "/selection/seat"}, + "seatUpcharge": { + "path": "/selection/seatExtraPrice" + }, + }, + { + "id": "addons_card", + "component": "FlightAddonsCard", + "title": "TRAVEL PERKS & UPGRADES", + "priorityBoarding": { + "path": "/selection/priorityBoarding" + }, + "extraBag": {"path": "/selection/extraBag"}, + "children": [ + "addon_priority_cb", + "addon_bag_cb", + ], + }, + { + "id": "addon_priority_cb", + "component": "CheckBox", + "label": "Zone 1 Priority Boarding & FastTrack", + "value": {"path": "/selection/priorityBoarding"}, + }, + { + "id": "addon_bag_cb", + "component": "CheckBox", + "label": "Extra 23kg Checked Bag", + "value": {"path": "/selection/extraBag"}, + }, + { + "id": "fare_footer", + "component": "LiveFareSummaryFooter", + "baseFare": {"path": "/flight/baseFare"}, + "selectedSeat": {"path": "/selection/seat"}, + "seatUpcharge": { + "path": "/selection/seatExtraPrice" + }, + "priorityBoarding": { + "path": "/selection/priorityBoarding" + }, + "extraBag": {"path": "/selection/extraBag"}, + "confirmBtnText": "Confirm Seat & Lock Fare", + "action": { + "event": { + "name": "confirm_flight_config", + "context": { + "agentId": title, + "selectionType": ( + "confirm_flight_config" + ), + }, + } + }, + }, + ], + }, + }, + ], + ) + + # ======================================================================= + # ACT 3: Live Surgical Server Data Patch (mid-selection updateDataModel) + # ======================================================================= + session.pause_events[title].clear() + try: + await asyncio.wait_for( + session.pause_events[title].wait(), + timeout=LIVE_SEAT_PATCH_DELAY_SEC, + ) + except asyncio.TimeoutError: + contested_seat = config.get("contestedSeat", "2C") + patched_occupied = list( + dict.fromkeys(config["occupiedSeats"] + [contested_seat]) + ) + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/cabin/occupiedSeats", + "value": patched_occupied, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/cabin/livePatchedSeat", + "value": contested_seat, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/cabin/livePatchMessage", + "value": ( + f"Seat {contested_seat} just booked by another" + " traveler" + ), + }, + }, + ], + ) + await session.pause_events[title].wait() + + # ======================================================================= + # ACT 4: Synchronized clientDataModel & Perforated Boarding Pass Morph + # ======================================================================= + act3_resp = session.results.get(title) + selected_seat = "1A" + seat_extra = 85 + priority_boarding = False + extra_bag = False + base_fare = int(chosen_opt["fare"]) + + if isinstance(act3_resp, dict): + client_dm = act3_resp.get("clientDataModel") or {} + if isinstance(client_dm, dict) and "a2uiClientDataModel" in client_dm: + client_dm = client_dm["a2uiClientDataModel"].get( + "v0.9", client_dm["a2uiClientDataModel"] + ) + surfaces_dm = ( + client_dm.get("surfaces", client_dm) + if isinstance(client_dm, dict) + else {} + ) + surface_dm = ( + surfaces_dm.get(title, {}) if isinstance(surfaces_dm, dict) else {} + ) + selection_dm = ( + surface_dm.get("selection", {}) + if isinstance(surface_dm, dict) + else {} + ) + flight_dm = ( + surface_dm.get("flight", {}) if isinstance(surface_dm, dict) else {} + ) + + selected_seat = ( + selection_dm.get("seat") + or act3_resp.get("selectedSeat") + or act3_resp.get("seat") + or "1A" + ) + if selected_seat.startswith(("1", "2")): + default_seat_extra = 85 + elif selected_seat.startswith("3"): + default_seat_extra = 35 + else: + default_seat_extra = 0 + + seat_extra = int(selection_dm.get("seatExtraPrice", default_seat_extra)) + priority_boarding = bool( + selection_dm.get( + "priorityBoarding", act3_resp.get("priorityBoarding", False) + ) + ) + extra_bag = bool( + selection_dm.get("extraBag", act3_resp.get("extraBag", False)) + ) + base_fare = int(flight_dm.get("baseFare", base_fare)) + elif isinstance(act3_resp, str) and act3_resp: + selected_seat = act3_resp + seat_extra = ( + 85 + if selected_seat.startswith(("1", "2")) + else (35 if selected_seat.startswith("3") else 0) + ) + + computed_total = ( + base_fare + + seat_extra + + (25 if priority_boarding else 0) + + (45 if extra_bag else 0) + ) + if isinstance(act3_resp, dict) and act3_resp.get("totalFare"): + total_fare = int(act3_resp["totalFare"]) + else: + total_fare = computed_total + + is_business = selected_seat.startswith(("1", "2")) + boarding_group = ( + "ZONE 1" + if (priority_boarding or is_business) + else config["boardingGroup"] + ) + pnr_suffix = "".join(ch for ch in config["destCode"] if ch.isalpha())[:2] + carrier_code = ( + "".join( + ch for ch in chosen_opt.get("flightNumber", "") if ch.isalpha() + )[:2] + or "JP" + ) + flight_time_range = ( + f"{chosen_opt['departureTime']} – {chosen_opt['arrivalTime']}" + ) + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/seat", + "value": selected_seat, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/priorityBoarding", + "value": priority_boarding, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/extraBag", + "value": extra_bag, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/totalFare", + "value": total_fare, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/flightTimeRange", + "value": flight_time_range, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/boardingGroup", + "value": boarding_group, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/confirmationCode", + "value": f"{carrier_code}-{pnr_suffix}9", + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/flight/statusBadge", + "value": "BOARDING PASS ISSUED", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "PerforatedBoardingPass", + "airline": {"path": "/flight/airline"}, + "originCode": {"path": "/flight/originCode"}, + "originCity": {"path": "/flight/originCity"}, + "destCode": {"path": "/flight/destCode"}, + "destCity": {"path": "/flight/destCity"}, + "flightTime": {"path": "/flight/flightTimeRange"}, + "seat": {"path": "/selection/seat"}, + "gate": {"path": "/flight/gate"}, + "terminal": {"path": "/flight/terminal"}, + "boardingTime": {"path": "/flight/boardingGroup"}, + "confirmationCode": { + "path": "/flight/confirmationCode" + }, + "totalFare": {"path": "/flight/totalFare"}, + "priorityBoarding": { + "path": "/selection/priorityBoarding" + }, + "extraBag": {"path": "/selection/extraBag"}, + }], + }, + }, + ], + ) + + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(0.5) + session.completed_steps[title] = 2 + + +class FlightSimulationAgent(BaseSimulationAgent): + name: str = "flight" + items: list[dict[str, Any]] = [] + full_itinerary: list[dict[str, Any]] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleFlightSimulationAgent( + name=f"single_flight_{i}", + item_title=item.get("title", "Flight"), + item_data=item, + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent( + name="flights_parallel", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + diff --git a/jetpacker/server/agents/hotel.py b/jetpacker/server/agents/hotel.py new file mode 100644 index 00000000..0b994d2e --- /dev/null +++ b/jetpacker/server/agents/hotel.py @@ -0,0 +1,690 @@ +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, AsyncGenerator + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.events.event import Event +from google.genai import types + +from agents.base import ( + BOOKING_ASSISTANT_CATALOG_ID, + PROGRESSIVE_SKELETON_DELAY_SEC, + BaseSimulationAgent, + _get_genai_client, + sessions, +) +from schemas import HotelConfigProposal, HotelRoomOptionSchema + +def _build_fallback_hotel_config( + item: dict[str, Any], full_itinerary: list[dict[str, Any]] +) -> dict[str, Any]: + """Builds route- and trip-accurate baseline hotel metadata from itinerary data.""" + title = item.get("title", "Hotel") + hotel_detail = item.get("hotelDetail") or {} + raw_loc = ( + hotel_detail.get("address") + or item.get("tripLocation") + or item.get("location") + or "" + ).strip() + if not raw_loc or raw_loc.lower() == title.lower(): + location = item.get("tripLocation") or "Mountain View, CA" + else: + location = raw_loc + + rating = hotel_detail.get("rating") or "4.8" + location_label = f"{location} • ★ {rating}" + + check_in_date = ( + hotel_detail.get("checkInDate") + or (item.get("dateLabel") or "OCT 27").upper() + ) + check_out_date = hotel_detail.get("checkOutDate") or "OCT 31" + nights = int(hotel_detail.get("nights") or 4) + + # Parse baseline rate from hotelDetail.pricePerNight if present + raw_price = str(hotel_detail.get("pricePerNight") or "240") + digits = re.findall(r"\d+", raw_price.replace(",", "")) + base_rate = int(digits[0]) if digits else 240 + if base_rate > 2000: + # Convert KRW/JPY-style numbers into USD-equivalent nightly integer for card display + base_rate = 260 + + conf_code = hotel_detail.get("confNumber") or "RG-8842" + venues = [ + ev.get("title", "") + for ev in full_itinerary + if ev.get("type") not in ("ACCOMMODATION", "TRANSPORTATION") + and ev.get("title") + ] + venue_hint = venues[0] if venues else "your itinerary activities" + + suites = [ + { + "id": "suite_1", + "name": ( + f"{title.replace('The ', '').split()[0]} Signature King Suite" + ), + "amenities": "Private Balcony • Ergonomic Studio Desk • Espresso Bar", + "rate": base_rate, + "badge": "POPULAR • BALCONY VIEW", + }, + { + "id": "suite_2", + "name": "Garden Courtyard Studio", + "amenities": "Courtyard View • Rainfall Shower • Quiet Wing", + "rate": max(140, base_rate - 45), + "badge": "QUIET RETREAT", + }, + { + "id": "suite_3", + "name": "Skyline Executive Loft", + "amenities": ( + "Top-Floor Panoramic View • Deep Soaking Tub • Lounge Access" + ), + "rate": base_rate + 70, + "badge": "VIP UPGRADE", + }, + ] + + return { + "hotelName": title, + "locationLabel": location_label, + "checkInDate": check_in_date, + "checkOutDate": check_out_date, + "nights": nights, + "rationale": ( + f"Well-situated for {venue_hint}, with quiet rooms and flexible" + " check-in." + ), + "liveUpdateAlert": ( + "Front Desk Live Update: Complimentary welcome drink & early" + f" check-in waiver unlocked at {title}!" + ), + "roomNumber": "SUITE 418", + "confirmationCode": conf_code, + "suites": suites, + } + + + +class SingleHotelSimulationAgent(BaseSimulationAgent): + name: str = "single_hotel" + item_title: str = "Hotel" + item_data: dict[str, Any] = {} + full_itinerary: list[dict[str, Any]] = [] + + async def _generate_hotel_config( + self, fallback: dict[str, Any] + ) -> dict[str, Any]: + try: + client = _get_genai_client() + prompt = ( + "You are a luxury hotel inventory engine generating an A2UI v0.9" + " hotel configurator payload.\n" + f"Hotel event item: {json.dumps(self.item_data)}\n" + f"Full trip itinerary context: {json.dumps(self.full_itinerary)}\n" + f"Baseline hotel defaults: {json.dumps(fallback)}\n" + "Return a realistic HotelConfigProposal JSON tailored to this exact" + " hotel, city, and trip itinerary:\n" + "- Keep `checkInDate`, `checkOutDate`, and `nights` consistent with" + " the baseline hotel defaults.\n" + "- `rationale`: 1 concise sentence explaining why this property fits" + " the traveler's itinerary venues (never mention 'AI' or 'A2UI').\n" + "- `suites`: Provide 3 distinct room/suite tiers in this hotel with" + " authentic names, 3 bulleted amenities separated by ' • ', integer" + " USD nightly `rate` (between 150 and 950), and a short room-specific" + " `badge` (e.g. 'POPULAR • BALCONY VIEW', 'QUIET RETREAT', 'VIP" + " UPGRADE' — never put distance to venue on a room badge since all" + " rooms are in the same hotel, and never mention 'AI' or 'A2UI')." + ) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=HotelConfigProposal, + temperature=0.3, + ), + ) + if response.text: + parsed = json.loads(response.text) + if not parsed.get("suites"): + parsed["suites"] = fallback["suites"] + for idx, s in enumerate(parsed["suites"]): + s["id"] = f"suite_{idx + 1}" + parsed["nights"] = int(parsed.get("nights") or fallback["nights"]) + parsed["checkInDate"] = ( + parsed.get("checkInDate") or fallback["checkInDate"] + ) + parsed["checkOutDate"] = ( + parsed.get("checkOutDate") or fallback["checkOutDate"] + ) + print( + f"INFO: Gemini generated hotel config for {self.item_title}:" + f" {[s.get('name') for s in parsed['suites']]}" + ) + return parsed + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"WARN: Gemini hotel config fallback used for {self.item_title}: {e}" + ) + return fallback + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + baseline = _build_fallback_hotel_config(self.item_data, self.full_itinerary) + base_first_suite = baseline["suites"][0] + + # ========================================================================= + # STEP 1: Progressive HotelConfiguratorShell + HotelRoomSelector + # ========================================================================= + if session.completed_steps.get(title, 0) < 1: + initial_hotel_dm = { + "hotel": { + "hotelName": title, + "location": baseline["locationLabel"], + "checkInDate": baseline["checkInDate"], + "checkOutDate": baseline["checkOutDate"], + "nights": baseline["nights"], + "statusBadge": "SCANNING SUITES", + "aiReason": baseline["rationale"], + "optionStrings": [], + "livePatchMessage": "", + }, + "selection": { + "roomId": base_first_suite["id"], + "roomName": base_first_suite["name"], + "nightlyRate": base_first_suite["rate"], + "checkInTime": "11:00 AM Early", + "floorPreference": "High Floor • Quiet", + "breakfastIncluded": False, + "lateCheckout": False, + }, + } + + # Emit progressive shell immediately so skeleton shimmer renders while Gemini runs + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": title, + "catalogId": BOOKING_ASSISTANT_CATALOG_ID, + "sendDataModel": True, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/", + "value": initial_hotel_dm, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "HotelConfiguratorShell", + "hotelName": {"path": "/hotel/hotelName"}, + "location": {"path": "/hotel/location"}, + "checkInDate": {"path": "/hotel/checkInDate"}, + "checkOutDate": {"path": "/hotel/checkOutDate"}, + "nights": {"path": "/hotel/nights"}, + "statusBadge": {"path": "/hotel/statusBadge"}, + "bodySlot": "hotel_room_selector", + }], + }, + }, + ], + ) + + # Call Gemini 2.5 Flash to generate hotel suites & itinerary rationale + config = await self._generate_hotel_config(baseline) + suite_options = config["suites"] + first_suite = suite_options[0] + option_strings = [ + f"{opt['id']}|{opt['name']}|{opt['amenities']}|{opt['rate']}|{opt['badge']}" + for opt in suite_options + ] + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel", + "value": { + "hotelName": title, + "location": config.get( + "locationLabel", baseline["locationLabel"] + ), + "checkInDate": config["checkInDate"], + "checkOutDate": config["checkOutDate"], + "nights": config["nights"], + "statusBadge": "SUITES READY", + "aiReason": config.get( + "rationale", baseline["rationale"] + ), + "optionStrings": option_strings, + "livePatchMessage": "", + }, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/roomId", + "value": first_suite["id"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/roomName", + "value": first_suite["name"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/nightlyRate", + "value": first_suite["rate"], + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "hotel_room_selector", + "component": "HotelRoomSelector", + "aiReason": {"path": "/hotel/aiReason"}, + "options": {"path": "/hotel/optionStrings"}, + "selectedRoomId": {"path": "/selection/roomId"}, + "selectedRoomName": {"path": "/selection/roomName"}, + "nightlyRate": {"path": "/selection/nightlyRate"}, + "confirmBtnText": "Customize Stay & Check-In", + "action": { + "event": { + "name": "select_hotel_room", + "context": { + "agentId": title, + "selectionType": "hotel_room", + }, + } + }, + }], + }, + }, + ], + ) + + if title not in session.pause_events: + session.pause_events[title] = asyncio.Event() + else: + session.pause_events[title].clear() + + await session.pause_events[title].wait() + session.completed_steps[title] = 1 + else: + config = await self._generate_hotel_config(baseline) + suite_options = config["suites"] + first_suite = suite_options[0] + + # Resolve selected suite from Step 1 + step1_resp = session.results.get(title) + chosen_suite = first_suite + if isinstance(step1_resp, dict): + sel_room_id = step1_resp.get("selectedRoomId") + sel_opt = step1_resp.get("selectedOption") or step1_resp.get("time") or "" + for opt in suite_options: + if (sel_room_id and opt["id"] == sel_room_id) or ( + sel_opt and opt["name"] in str(sel_opt) + ): + chosen_suite = opt + break + + # ========================================================================= + # STEP 2: HotelStayCustomizer + Reactive Packages + Live Front-Desk Patch + # ========================================================================= + if session.completed_steps.get(title, 0) < 2: + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/roomName", + "value": chosen_suite["name"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/nightlyRate", + "value": chosen_suite["rate"], + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel/statusBadge", + "value": "CUSTOMIZE STAY", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [ + { + "id": "root", + "component": "HotelConfiguratorShell", + "hotelName": {"path": "/hotel/hotelName"}, + "location": {"path": "/hotel/location"}, + "checkInDate": {"path": "/hotel/checkInDate"}, + "checkOutDate": {"path": "/hotel/checkOutDate"}, + "nights": {"path": "/hotel/nights"}, + "statusBadge": {"path": "/hotel/statusBadge"}, + "bodySlot": "hotel_stay_customizer", + "footerSlot": "hotel_fare_footer", + }, + { + "id": "hotel_stay_customizer", + "component": "HotelStayCustomizer", + "selectedRoomName": { + "path": "/selection/roomName" + }, + "checkInTime": {"path": "/selection/checkInTime"}, + "floorPreference": { + "path": "/selection/floorPreference" + }, + "liveAlert": {"path": "/hotel/livePatchMessage"}, + "children": [ + "hotel_breakfast_cb", + "hotel_checkout_cb", + ], + }, + { + "id": "hotel_breakfast_cb", + "component": "CheckBox", + "label": ( + "Artisan Breakfast & Espresso Bar (+$28/nt)" + ), + "value": {"path": "/selection/breakfastIncluded"}, + }, + { + "id": "hotel_checkout_cb", + "component": "CheckBox", + "label": ( + "Guaranteed 4:00 PM Late Checkout (+$45)" + ), + "value": {"path": "/selection/lateCheckout"}, + }, + { + "id": "hotel_fare_footer", + "component": "HotelLiveSummaryFooter", + "nightlyRate": {"path": "/selection/nightlyRate"}, + "nights": {"path": "/hotel/nights"}, + "checkInTime": {"path": "/selection/checkInTime"}, + "floorPreference": { + "path": "/selection/floorPreference" + }, + "breakfastIncluded": { + "path": "/selection/breakfastIncluded" + }, + "lateCheckout": { + "path": "/selection/lateCheckout" + }, + "confirmBtnText": "Confirm Suite & Issue Key", + "action": { + "event": { + "name": "confirm_hotel_config", + "context": { + "agentId": title, + "selectionType": ( + "confirm_hotel_config" + ), + }, + } + }, + }, + ], + }, + }, + ], + ) + + session.pause_events[title].clear() + try: + await asyncio.wait_for( + session.pause_events[title].wait(), + timeout=2.8, + ) + except asyncio.TimeoutError: + yield self._create_a2ui_event( + ctx, + author=title, + messages=[{ + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel/livePatchMessage", + "value": config.get( + "liveUpdateAlert", baseline["liveUpdateAlert"] + ), + }, + }], + ) + await session.pause_events[title].wait() + + # ======================================================================= + # STEP 3: DigitalHotelKeyPass Morph + # ======================================================================= + step2_resp = session.results.get(title) + check_in_time = "11:00 AM Early" + floor_pref = "High Floor • Quiet" + breakfast = False + late_checkout = False + nightly_rate = int(chosen_suite["rate"]) + + if isinstance(step2_resp, dict): + client_dm = step2_resp.get("clientDataModel") or {} + if isinstance(client_dm, dict) and "a2uiClientDataModel" in client_dm: + client_dm = client_dm["a2uiClientDataModel"].get( + "v0.9", client_dm["a2uiClientDataModel"] + ) + surfaces_dm = ( + client_dm.get("surfaces", client_dm) + if isinstance(client_dm, dict) + else {} + ) + surface_dm = ( + surfaces_dm.get(title, {}) if isinstance(surfaces_dm, dict) else {} + ) + selection_dm = ( + surface_dm.get("selection", {}) + if isinstance(surface_dm, dict) + else {} + ) + check_in_time = ( + selection_dm.get("checkInTime") + or step2_resp.get("checkInTime") + or "11:00 AM Early" + ) + floor_pref = ( + selection_dm.get("floorPreference") + or step2_resp.get("floorPreference") + or "High Floor • Quiet" + ) + breakfast = bool( + selection_dm.get( + "breakfastIncluded", step2_resp.get("breakfastIncluded", False) + ) + ) + late_checkout = bool( + selection_dm.get( + "lateCheckout", step2_resp.get("lateCheckout", False) + ) + ) + nightly_rate = int( + selection_dm.get("nightlyRate", chosen_suite["rate"]) + ) + + nights = int(config.get("nights") or baseline["nights"]) + computed_total = ( + (nightly_rate * nights) + + (28 * nights if breakfast else 0) + + (45 if late_checkout else 0) + ) + total_stay = ( + int(step2_resp["totalFare"]) + if isinstance(step2_resp, dict) and step2_resp.get("totalFare") + else computed_total + ) + room_number = config.get("roomNumber") or baseline["roomNumber"] + conf_code = config.get("confirmationCode") or baseline["confirmationCode"] + + yield self._create_a2ui_event( + ctx, + author=title, + messages=[ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/checkInTime", + "value": check_in_time, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/floorPreference", + "value": floor_pref, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/breakfastIncluded", + "value": breakfast, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/selection/lateCheckout", + "value": late_checkout, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel/totalStayPrice", + "value": total_stay, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel/roomNumber", + "value": room_number, + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": title, + "path": "/hotel/confirmationCode", + "value": conf_code, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": title, + "components": [{ + "id": "root", + "component": "DigitalHotelKeyPass", + "hotelName": {"path": "/hotel/hotelName"}, + "roomName": {"path": "/selection/roomName"}, + "roomNumber": {"path": "/hotel/roomNumber"}, + "floorPreference": { + "path": "/selection/floorPreference" + }, + "checkInDate": {"path": "/hotel/checkInDate"}, + "checkOutDate": {"path": "/hotel/checkOutDate"}, + "checkInTime": {"path": "/selection/checkInTime"}, + "confirmationCode": { + "path": "/hotel/confirmationCode" + }, + "totalStayPrice": {"path": "/hotel/totalStayPrice"}, + "breakfastIncluded": { + "path": "/selection/breakfastIncluded" + }, + "lateCheckout": {"path": "/selection/lateCheckout"}, + }], + }, + }, + ], + ) + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(0.4) + session.completed_steps[title] = 2 + + +class HotelSimulationAgent(BaseSimulationAgent): + name: str = "hotel" + items: list[dict[str, Any]] = [] + full_itinerary: list[dict[str, Any]] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleHotelSimulationAgent( + name=f"single_hotel_{i}", + item_title=item.get("title", "Hotel"), + item_data=item, + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent(name="hotels_parallel", sub_agents=sub_agents) + + async for event in parallel_agent.run_async(ctx): + yield event + diff --git a/jetpacker/server/agents/orchestrator.py b/jetpacker/server/agents/orchestrator.py new file mode 100644 index 00000000..4945074d --- /dev/null +++ b/jetpacker/server/agents/orchestrator.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, AsyncGenerator + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.events.event import Event +from google.genai import types + +from agents.activity import ( + MuseumSimulationAgent, + RestaurantSimulationAgent, + SingleMuseumSimulationAgent, + SingleRestaurantSimulationAgent, +) +from agents.base import ( + BOOKING_ASSISTANT_CATALOG_ID, + BaseSimulationAgent, + SessionState, + _get_genai_client, + _resolve_airport, + sessions, +) +from agents.flight import FlightSimulationAgent, SingleFlightSimulationAgent +from agents.hotel import HotelSimulationAgent, SingleHotelSimulationAgent + +class ItineraryOrchestratorAgent(BaseSimulationAgent): + name: str = "booking" + + def _create_chat_reply( + self, + ctx: InvocationContext, + message: str, + surface_id: str | None = None, + ) -> Event: + payload: dict[str, Any] = {"chatMessage": message} + if surface_id: + payload["surfaceId"] = surface_id + return self._create_text_event( + ctx, json.dumps(payload), author="__assistant__" + ) + + async def _generate_conversational_reply( + self, + user_prompt: str, + itinerary: list[dict[str, Any]], + ) -> str: + first_item = itinerary[0] if itinerary else {} + trip_title = first_item.get("tripTitle") or "your upcoming trip" + trip_location = first_item.get("tripLocation") or "" + item_titles = [e.get("title", "") for e in itinerary if e.get("title")][:4] + items_summary = ( + ", ".join(item_titles) + if item_titles + else "flights, hotel stays, dining, and activities" + ) + loc_suffix = f" to {trip_location}" if trip_location else "" + fallback = ( + f"Hello! I'm your Trip Concierge for {trip_title}{loc_suffix}. " + f"I can help you book or customize {items_summary}—just let me know " + "what you'd like to arrange!" + ) + try: + client = _get_genai_client() + prompt = ( + "You are a warm, helpful travel concierge in the JetSet 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" + "Reply directly to the user in 1-2 concise, friendly sentences " + "as plain text. Mention relevant items from their trip that you " + "can help book (such as flights, hotel, dinner, or activities) " + "if appropriate. Never mention 'A2UI' or internal system terms." + ) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + temperature=0.4, + ), + ) + if response.text and response.text.strip(): + return response.text.strip() + except Exception as e: # pylint: disable=broad-exception-caught + print(f"WARN: Gemini conversational reply fallback used: {e}") + return fallback + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if ctx.session.id not in sessions: + if len(sessions) > 500: + for k in list(sessions.keys())[:100]: + sessions.pop(k, None) + sessions[ctx.session.id] = SessionState() + + user_text = "" + if ctx.user_content and ctx.user_content.parts: + user_text = ctx.user_content.parts[0].text or "" + + command = "start" + intent = "" + raw_prompt = "" + prompt_text = "" + target_title = "" + try: + data = json.loads(user_text) + itinerary = data.get("itinerary", []) + command = data.get("command", "start") + intent = data.get("intent", "").lower() + raw_prompt = data.get("prompt", "") + prompt_text = raw_prompt.lower() + target_title = data.get("targetTitle", "") + print( + f"DEBUG: command={command}, intent={intent}, target={target_title}," + f" itinerary_len={len(itinerary)}" + ) + except json.JSONDecodeError: + itinerary = [ + {"type": "TRANSPORTATION", "title": "CDG > SFO"}, + {"type": "ACCOMMODATION", "title": "The Redwood Grove Hotel"}, + {"type": "FOOD_AND_DRINK", "title": "Dinner @ Quantum Bites"}, + {"type": "CULTURE", "title": "SFMOMA Museum Visit"}, + ] + + flight_items = [e for e in itinerary if e.get("type") == "TRANSPORTATION"] + hotel_items = [e for e in itinerary if e.get("type") == "ACCOMMODATION"] + restaurant_items = [ + e for e in itinerary if e.get("type") == "FOOD_AND_DRINK" + ] + museum_items = [ + e for e in itinerary if e.get("type") in ["CULTURE", "ACTIVITY"] + ] + + # Conversational single-flow mode triggered by user chat message + if command == "chat": + combined = f"{intent} {prompt_text}".lower() + + if intent == "hotel" or ( + intent != "conversation" + and ( + "hotel" in combined + or "stay" in combined + or "room" in combined + or "suite" in combined + or "redwood" in combined + or "meurice" in combined + ) + ): + chosen = next( + (h for h in hotel_items if h.get("title") == target_title), + hotel_items[0] + if hotel_items + else { + "type": "ACCOMMODATION", + "title": target_title or "The Redwood Grove Hotel", + "location": "Mountain View, CA", + }, + ) + h_title = chosen.get("title", "The Redwood Grove Hotel") + h_nights = (chosen.get("hotelDetail") or {}).get("nights", 4) + sessions[ctx.session.id].completed_steps[h_title] = 0 + yield self._create_chat_reply( + ctx, + f"I've pulled up suite availability at {h_title} for your" + f" {h_nights}-night stay. Choose your room tier below, then" + " customize your check-in window and hospitality packages:", + surface_id=h_title, + ) + agent = SingleHotelSimulationAgent( + name="chat_hotel", + item_title=h_title, + item_data=chosen, + full_itinerary=itinerary, + ) + async for event in agent.run_async(ctx): + yield event + yield self._create_chat_reply( + ctx, + f"Your NFC Digital Room Key at {h_title} is active and" + " saved to your trip! Would you like to book your flights or" + " reserve dinner next?", + ) + return + + if intent == "dining" or ( + intent != "conversation" + and ( + "dinner" in combined + or "lunch" in combined + or "restaurant" in combined + or "table" in combined + or "quantum" in combined + or "dining" in combined + or "verne" in combined + ) + ): + chosen = next( + (r for r in restaurant_items if r.get("title") == target_title), + next( + ( + r + for r in restaurant_items + if "dinner" in r.get("title", "").lower() + ), + restaurant_items[0] + if restaurant_items + else { + "type": "FOOD_AND_DRINK", + "title": target_title or "Dinner @ Quantum Bites", + }, + ), + ) + r_title = chosen.get("title", "Dinner @ Quantum Bites") + sessions[ctx.session.id].completed_steps[r_title] = 0 + yield self._create_chat_reply( + ctx, + f"I found prime availability for {r_title}. Select your" + " party size and seating zone below:", + surface_id=r_title, + ) + agent = SingleRestaurantSimulationAgent( + name="chat_restaurant", + item_title=r_title, + item_data=chosen, + full_itinerary=itinerary, + ) + async for event in agent.run_async(ctx): + yield event + yield self._create_chat_reply( + ctx, + f"Your table for {r_title} is locked in! Anything else you'd like" + " to book?", + ) + return + + if intent == "activity" or ( + intent != "conversation" + and ( + "museum" in combined + or "activity" in combined + or "sfmoma" in combined + or "cruise" in combined + or "tour" in combined + or "louvre" in combined + ) + ): + chosen = next( + (m for m in museum_items if m.get("title") == target_title), + museum_items[0] + if museum_items + else { + "type": "CULTURE", + "title": target_title or "SFMOMA Museum Visit", + }, + ) + m_title = chosen.get("title", "SFMOMA Museum Visit") + sessions[ctx.session.id].completed_steps[m_title] = 0 + yield self._create_chat_reply( + ctx, + f"Here are the timed-entry options for {m_title}. Choose your" + " preferred window:", + surface_id=m_title, + ) + agent = SingleMuseumSimulationAgent( + name="chat_museum", + item_title=m_title, + item_data=chosen, + full_itinerary=itinerary, + ) + async for event in agent.run_async(ctx): + yield event + yield self._create_chat_reply( + ctx, + f"Your mobile passes for {m_title} are ready in your trip wallet!", + ) + return + + if intent == "flight" or ( + intent != "conversation" + and ( + "flight" in combined + or "fly" in combined + or "plane" in combined + or "airline" in combined + or "airport" in combined + or "seat" in combined + or "boarding" in combined + ) + ): + chosen = next( + (f for f in flight_items if f.get("title") == target_title), + flight_items[0] + if flight_items + else { + "type": "TRANSPORTATION", + "title": target_title or "CDG > SFO", + "location": "SFO International Terminal", + }, + ) + f_title = chosen.get("title", "CDG > SFO") + sessions[ctx.session.id].completed_steps[f_title] = 0 + yield self._create_chat_reply( + ctx, + f"I found 3 flights for your {f_title} route. Select your preferred" + " departure below, then customize your cabin seat and travel" + " perks:", + surface_id=f_title, + ) + agent = SingleFlightSimulationAgent( + name="chat_flight", + item_title=f_title, + item_data=chosen, + full_itinerary=itinerary, + ) + async for event in agent.run_async(ctx): + yield event + yield self._create_chat_reply( + ctx, + f"Your boarding pass for {f_title} is issued and saved to your" + " itinerary! Would you like to book your hotel stay or reserve a" + " table for dinner next?", + ) + return + + # General conversational message (e.g. "hello!", "what can we do?") + reply_text = await self._generate_conversational_reply( + raw_prompt or prompt_text, itinerary + ) + yield self._create_chat_reply(ctx, reply_text) + yield self._create_end_event(ctx, author="__assistant__") + return + + # Legacy parallel fallback if command == "start" + sub_agents = [] + if hotel_items: + sub_agents.append( + HotelSimulationAgent(items=hotel_items, full_itinerary=itinerary) + ) + if museum_items: + sub_agents.append( + MuseumSimulationAgent(items=museum_items, full_itinerary=itinerary) + ) + if restaurant_items: + sub_agents.append( + RestaurantSimulationAgent( + items=restaurant_items, full_itinerary=itinerary + ) + ) + if flight_items: + sub_agents.append( + FlightSimulationAgent(items=flight_items, full_itinerary=itinerary) + ) + + if not sub_agents: + yield self._create_text_event( + ctx, "No relevant agents found for itinerary." + ) + return + + parallel_agent = ParallelAgent( + name="parallel_orchestrator", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + diff --git a/jetpacker/server/booking/__init__.py b/jetpacker/server/booking/__init__.py index 05368a38..2e2fc639 100644 --- a/jetpacker/server/booking/__init__.py +++ b/jetpacker/server/booking/__init__.py @@ -6,8 +6,13 @@ def __getattr__(name: str) -> Any: if name == "root_agent": - if "main" in sys.modules: - return sys.modules["main"].ItineraryOrchestratorAgent() + main_mod = sys.modules.get("main") or ( + sys.modules.get("__main__") + if hasattr(sys.modules.get("__main__"), "ItineraryOrchestratorAgent") + else None + ) + if main_mod is not None: + return main_mod.ItineraryOrchestratorAgent() import main return main.ItineraryOrchestratorAgent() diff --git a/jetpacker/server/booking/agent.py b/jetpacker/server/booking/agent.py index 05368a38..2e2fc639 100644 --- a/jetpacker/server/booking/agent.py +++ b/jetpacker/server/booking/agent.py @@ -6,8 +6,13 @@ def __getattr__(name: str) -> Any: if name == "root_agent": - if "main" in sys.modules: - return sys.modules["main"].ItineraryOrchestratorAgent() + main_mod = sys.modules.get("main") or ( + sys.modules.get("__main__") + if hasattr(sys.modules.get("__main__"), "ItineraryOrchestratorAgent") + else None + ) + if main_mod is not None: + return main_mod.ItineraryOrchestratorAgent() import main return main.ItineraryOrchestratorAgent() diff --git a/jetpacker/server/main.py b/jetpacker/server/main.py index a9a0a51e..fd6a1d2c 100644 --- a/jetpacker/server/main.py +++ b/jetpacker/server/main.py @@ -1,539 +1,73 @@ -import asyncio -import json -from typing import Any, AsyncGenerator +from __future__ import annotations + +import inspect +import os +from typing import Any import urllib.request import fastapi -from google.adk.agents.base_agent import BaseAgent -from google.adk.agents.invocation_context import InvocationContext -from google.adk.agents.parallel_agent import ParallelAgent from google.adk.cli.fast_api import get_fast_api_app from google.adk.cli.utils.base_agent_loader import BaseAgentLoader -from google.adk.events.event import Event -from google.adk.events.event_actions import EventActions -from google import genai -from google.genai import types -import inspect -import os -import pydantic import uvicorn -app = fastapi.FastAPI() - - -class SessionState: - - def __init__(self): - self.pause_events: dict[str, asyncio.Event] = {} - self.results: dict[str, any] = {} - self.completed_steps: dict[str, int] = {} - - -sessions: dict[str, SessionState] = {} - - -class BaseSimulationAgent(BaseAgent): - - async def _generate_text(self, prompt: str) -> str: - client = genai.Client(vertexai=True) - response = await asyncio.to_thread( - client.models.generate_content, - model="gemini-2.5-flash", - contents=prompt, - ) - text = response.text or "" - return text.strip() - - def _create_text_event( - self, ctx: InvocationContext, text: str, author: str | None = None - ) -> Event: - return Event( - invocation_id=ctx.invocation_id, - author=author or self.name, - content=types.Content(parts=[types.Part(text=text)]), - branch=ctx.branch, - ) - - def _create_end_event( - self, ctx: InvocationContext, author: str | None = None - ) -> Event: - return Event( - invocation_id=ctx.invocation_id, - author=author or self.name, - actions=EventActions(end_of_agent=True), - content=types.Content(parts=[]), - branch=ctx.branch, - ) - - def _create_options_event( - self, - ctx: InvocationContext, - title: str, - options: list[str], - message: str, - ui_type: str = "seat_map", - ) -> Event: - return self._create_text_event( - ctx, - json.dumps({ - "ui": ui_type, - "options": options, - "message": message, - }), - author=title, - ) - - -class SingleFlightSimulationAgent(BaseSimulationAgent): - name: str = "single_flight" - item_title: str = "Flight" - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - session = sessions[ctx.session.id] - title = self.item_title - - # Step 1: Time Confirmation - if session.completed_steps.get(title, 0) < 1: - confirmed = False - while not confirmed: - time_proposal = await self._generate_text( - f"Propose a single logical departure time for the flight '{title}'" - " (assume origin is France). Keep it under 10 words." - ) - - yield self._create_options_event( - ctx, - title, - options=[f"Confirm {time_proposal}", "Try Another Time"], - message=( - f"I found a flight at {time_proposal}. Please confirm or" - " request another time." - ), - ui_type="time_selection", - ) - - if title not in session.pause_events: - session.pause_events[title] = asyncio.Event() - else: - session.pause_events[title].clear() - - await session.pause_events[title].wait() - - response = session.results.get(title) - if response and "Confirm" in response: - confirmed = True - else: - session.results[title] = None - yield self._create_text_event( - ctx, "Searching for alternative flight times...", author=title - ) - await asyncio.sleep(2) - session.completed_steps[title] = 1 - - # Step 2: Seat Selection - if session.completed_steps.get(title, 0) < 2: - yield self._create_options_event( - ctx, - title, - options=["1A", "1B", "2A", "2B"], - message=f"Flight time confirmed! Please select a seat for {title}.", - ) - - session.pause_events[title].clear() - await session.pause_events[title].wait() - - selected_seat = session.results.get(title) or "Unknown" - - confirmed_text = await self._generate_text( - "Generate a SINGLE realistic status line for confirming the flight" - f" '{title}' with seat {selected_seat}. Do NOT include flight details" - " in the output, just the status." - ) - yield self._create_text_event(ctx, confirmed_text, author=title) - yield self._create_end_event(ctx, author=title) - await asyncio.sleep(1) - session.completed_steps[title] = 2 - - -class FlightSimulationAgent(BaseSimulationAgent): - name: str = "flight" - items: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - if not self.items: - return - - sub_agents = [ - SingleFlightSimulationAgent( - name=f"single_flight_{i}", - item_title=item.get("title", "Flight"), - ) - for i, item in enumerate(self.items) - ] - parallel_agent = ParallelAgent(name="flights_parallel", sub_agents=sub_agents) - - async for event in parallel_agent.run_async(ctx): - yield event - - -class SingleHotelSimulationAgent(BaseSimulationAgent): - name: str = "single_hotel" - item_title: str = "Hotel" - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - session = sessions[ctx.session.id] - title = self.item_title - - prompt = f""" - You are a travel agent booking a hotel. - Generate a brief justification (1-2 sentences) for choosing the hotel '{title}' for the user. - Mention that you are booking it for the correct number of nights based on the dates (assume 3 nights if dates are missing). - Explain why you chose it (e.g., proximity to other locations in the itinerary, reviews, or user preferences). - Context: Full itinerary is {self.full_itinerary} - """ - justification = await self._generate_text(prompt) - - status_text = f"{justification} Please confirm reservation." - yield self._create_text_event(ctx, status_text, author=title) - - # HITL Pause - pause_key = title - if pause_key not in session.pause_events: - session.pause_events[pause_key] = asyncio.Event() - - await session.pause_events[pause_key].wait() - - response = session.results.get(pause_key) - if response == "Confirmed": - confirmed_text = await self._generate_text( - "Generate a SINGLE realistic status line for confirming the hotel" - f" reservation for '{title}'. Do NOT include the hotel name in the" - " output, just the status (e.g., 'Reservation confirmed', 'Room" - " secured')." - ) - yield self._create_text_event(ctx, confirmed_text, author=title) - else: - yield self._create_text_event( - ctx, f"Reservation for '{title}' was not confirmed.", author=title - ) - - yield self._create_end_event(ctx, author=title) - await asyncio.sleep(1) - - -class HotelSimulationAgent(BaseSimulationAgent): - name: str = "hotel" - items: list[dict] = [] - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - if not self.items: - return - - sub_agents = [ - SingleHotelSimulationAgent( - name=f"single_hotel_{i}", - item_title=item.get("title", "Hotel"), - full_itinerary=self.full_itinerary, - ) - for i, item in enumerate(self.items) - ] - parallel_agent = ParallelAgent(name="hotels_parallel", sub_agents=sub_agents) - - async for event in parallel_agent.run_async(ctx): - yield event - - -class SingleMuseumSimulationAgent(BaseSimulationAgent): - name: str = "single_museum" - item_title: str = "Museum" - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - session = sessions[ctx.session.id] - title = self.item_title - - prompt = f""" - You are a travel agent booking museum tickets. - Generate a brief justification (1-2 sentences) for reserving tickets for the museum '{title}'. - Propose a logical time slot for the visit (e.g., morning or afternoon) and ask user to confirm. - Explain why you chose it (e.g., proximity to other events in the itinerary, popularity, or specific exhibitions). - Context: Full itinerary is {self.full_itinerary} - """ - justification = await self._generate_text(prompt) - - status_text = f"{justification} Please confirm tickets." - yield self._create_text_event(ctx, status_text, author=title) - - # HITL Pause - pause_key = title - if pause_key not in session.pause_events: - session.pause_events[pause_key] = asyncio.Event() - - await session.pause_events[pause_key].wait() - - response = session.results.get(pause_key) - if response == "Confirmed": - confirmed_text = await self._generate_text( - "Generate a SINGLE realistic status line for confirming tickets for" - f" '{title}'. Do NOT include the museum name in the output, just the" - " status (e.g., 'Tickets confirmed', 'Reservation secured')." - ) - yield self._create_text_event(ctx, confirmed_text, author=title) - else: - yield self._create_text_event( - ctx, f"Tickets for '{title}' were not confirmed.", author=title - ) - - await asyncio.sleep(1) - yield self._create_end_event(ctx, author=title) - - -class MuseumSimulationAgent(BaseSimulationAgent): - name: str = "museum" - items: list[dict] = [] - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - if not self.items: - return - - sub_agents = [ - SingleMuseumSimulationAgent( - name=f"single_museum_{i}", - item_title=item.get("title", "Museum"), - full_itinerary=self.full_itinerary, - ) - for i, item in enumerate(self.items) - ] - parallel_agent = ParallelAgent(name="museums_parallel", sub_agents=sub_agents) - - async for event in parallel_agent.run_async(ctx): - yield event - - yield self._create_end_event(ctx, author=self.name) - - -class SingleRestaurantSimulationAgent(BaseSimulationAgent): - name: str = "single_restaurant" - item_title: str = "Restaurant" - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - session = sessions[ctx.session.id] - title = self.item_title - - prompt = f""" - You are a travel agent booking a restaurant. - Generate a brief justification (1-2 sentences) for choosing the restaurant '{title}' for the user. - Explain why you chose it (e.g., proximity to other locations in the itinerary, cuisine, or reviews). - Context: Full itinerary is {self.full_itinerary} - """ - justification = await self._generate_text(prompt) - - # Ask for people count using options UI - yield self._create_options_event( - ctx, - title, - options=["1", "2", "3", "4+"], - message=( - f"{justification} Please select the number of people for the" - " reservation." - ), - ui_type="ticket_selection", - ) - - if title not in session.pause_events: - session.pause_events[title] = asyncio.Event() - else: - session.pause_events[title].clear() - - await session.pause_events[title].wait() - - people_count = session.results.get(title) or "Unknown" - - confirmed_text = await self._generate_text( - "Generate a SINGLE realistic status line for confirming a table for" - f" {people_count} people at '{title}'. Do NOT include the restaurant" - " name in the output, just the status (e.g., 'Table reserved for" - f" {people_count}', 'Reservation secured')." - ) - yield self._create_text_event(ctx, confirmed_text, author=title) - yield self._create_end_event(ctx, author=title) - await asyncio.sleep(1) - - -class RestaurantSimulationAgent(BaseSimulationAgent): - name: str = "restaurant" - items: list[dict] = [] - full_itinerary: list[dict] = [] - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - if not self.items: - return - - sub_agents = [ - SingleRestaurantSimulationAgent( - name=f"single_restaurant_{i}", - item_title=item.get("title", "Restaurant"), - full_itinerary=self.full_itinerary, - ) - for i, item in enumerate(self.items) - ] - parallel_agent = ParallelAgent( - name="restaurants_parallel", sub_agents=sub_agents - ) - - async for event in parallel_agent.run_async(ctx): - yield event - - -class ItineraryOrchestratorAgent(BaseSimulationAgent): - name: str = "booking" - - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - if ctx.session.id not in sessions: - sessions[ctx.session.id] = SessionState() - - user_text = "" - if ctx.user_content and ctx.user_content.parts: - user_text = ctx.user_content.parts[0].text - - try: - data = json.loads(user_text) - itinerary = data.get("itinerary", []) - print(f"DEBUG: Parsed itinerary: {itinerary}") - except json.JSONDecodeError: - yield self._create_text_event( - ctx, "Failed to parse itinerary. Running all agents as default." - ) - itinerary = [ - {"type": "TRANSPORTATION"}, - {"type": "CULTURE"}, - {"type": "FOOD_AND_DRINK"}, - ] - print("DEBUG: Failed to parse itinerary, using default.") - - sub_agents = [] - - # Handle Hotels group - hotel_items = [e for e in itinerary if e.get("type") == "ACCOMMODATION"] - if hotel_items: - sub_agents.append(HotelSimulationAgent(items=hotel_items, full_itinerary=itinerary)) - - # Handle Museums group - museum_items = [ - e for e in itinerary if e.get("type") in ["CULTURE", "ACTIVITY"] - ] - if museum_items: - sub_agents.append(MuseumSimulationAgent(items=museum_items, full_itinerary=itinerary)) - - # Handle Restaurants group - restaurant_items = [ - e for e in itinerary if e.get("type") == "FOOD_AND_DRINK" - ] - if restaurant_items: - sub_agents.append(RestaurantSimulationAgent(items=restaurant_items, full_itinerary=itinerary)) - - # Handle Flights group - flight_items = [e for e in itinerary if e.get("type") == "TRANSPORTATION"] - if flight_items: - sub_agents.append(FlightSimulationAgent(items=flight_items)) - - if not sub_agents: - yield self._create_text_event( - ctx, "No relevant agents found for itinerary." - ) - return - - parallel_agent = ParallelAgent( - name="parallel_orchestrator", sub_agents=sub_agents - ) - - async for event in parallel_agent.run_async(ctx): - yield event +from agents.base import SessionState, sessions +from agents.orchestrator import ItineraryOrchestratorAgent +from schemas import ( + ActivityOrDiningProposal, + FlightConfigProposal, + FlightOptionSchema, + HotelConfigProposal, + HotelRoomOptionSchema, + ResponseData, +) class BookingAgentLoader(BaseAgentLoader): - def load_agent(self, agent_name: str) -> BaseAgent: - if agent_name == "booking": - return ItineraryOrchestratorAgent() - raise ValueError(f"Unknown agent: {agent_name}") - - def list_agents(self) -> list[str]: - return ["booking"] + def load_agent(self, agent_name: str) -> Any: + return ItineraryOrchestratorAgent() +# Inspect ADK's get_fast_api_app signature to support both old and new signatures _adk_sig = inspect.signature(get_fast_api_app) -_adk_kwargs: dict[str, Any] = { - "agents_dir": ".", -} -if "web" in _adk_sig.parameters: - _adk_kwargs["web"] = False -if "agent_loader" in _adk_sig.parameters: - _adk_kwargs["agent_loader"] = BookingAgentLoader() -if "auto_create_session" in _adk_sig.parameters: - _adk_kwargs["auto_create_session"] = True +_adk_kwargs = {} +if "agents_dir" in _adk_sig.parameters: + _adk_kwargs["agents_dir"] = os.path.dirname(os.path.abspath(__file__)) +if "session_service_uri" in _adk_sig.parameters: + _adk_kwargs["session_service_uri"] = None +if "loader" in _adk_sig.parameters: + _adk_kwargs["loader"] = BookingAgentLoader() -print(f"DEBUG: Initializing ADK FastAPI with kwargs: {_adk_kwargs}") app = get_fast_api_app(**_adk_kwargs) -class ResponseData(pydantic.BaseModel): - seat: str | None = None - time: str | None = None - selectedFlightId: str | None = None - selectedRoomId: str | None = None - nightlyRate: int | None = None - checkInTime: str | None = None - floorPreference: str | None = None - breakfastIncluded: bool | None = None - lateCheckout: bool | None = None - tickets: str | None = None - people: str | None = None - confirmed: bool | None = None - selectionType: str | None = None - priorityBoarding: bool | None = None - extraBag: bool | None = None - totalFare: int | None = None - clientDataModel: dict[str, Any] | None = None - - @app.post("/respond") -async def respond(session_id: str, data: ResponseData, agent_id: str | None = None): +@app.post("/v2/respond") +async def respond( + session_id: str, data: ResponseData, agent_id: str | None = None +) -> dict[str, str]: if session_id not in sessions: raise fastapi.HTTPException(status_code=404, detail="Session not found") session = sessions[session_id] - - # For backward compatibility or default flight use flight_Flight pause_key = agent_id or "flight_Flight" - + + # Store structured payload so A2UI v0.9 agents can read clientDataModel, + # while also supporting legacy string expectations. if ( data.clientDataModel is not None - or data.selectionType in ("time", "confirm_flight_config", "hotel_room", "confirm_hotel_config") + or data.selectionType + in ( + "time", + "confirm_flight_config", + "hotel_room", + "confirm_hotel_config", + ) or data.selectedFlightId is not None or data.selectedRoomId is not None + or data.selectedSeat is not None + or data.checkInTime is not None + or data.floorPreference is not None or data.priorityBoarding is not None or data.extraBag is not None or data.breakfastIncluded is not None @@ -542,43 +76,43 @@ async def respond(session_id: str, data: ResponseData, agent_id: str | None = No session.results[pause_key] = data.model_dump(exclude_none=True) elif data.seat: session.results[pause_key] = data.seat + elif data.selectedSeat: + session.results[pause_key] = data.selectedSeat elif data.time: session.results[pause_key] = data.time - elif data.tickets: - session.results[pause_key] = data.tickets - elif data.people: - session.results[pause_key] = data.people - elif data.confirmed: - session.results[pause_key] = "Confirmed" + elif data.selectedOption: + session.results[pause_key] = data.selectedOption + else: + session.results[pause_key] = "confirmed" if pause_key in session.pause_events: session.pause_events[pause_key].set() else: - raise fastapi.HTTPException(status_code=404, detail=f"Pause for {pause_key} not active") + raise fastapi.HTTPException( + status_code=404, detail=f"Pause for {pause_key} not active" + ) return {"status": "success", "message": "User response received"} -for route in app.routes: - path = getattr(route, "path", "N/A") - methods = getattr(route, "methods", "N/A") - print(f"DEBUG: Bound route: {path} methods: {methods}") - - @app.get("/stream") -def get_stream_token(auth_only: bool = False): +@app.get("/v2/stream") +def get_stream_token(auth_only: bool = False) -> dict[str, str]: """Returns an OIDC token for direct streaming access.""" del auth_only # Unused parameter kept for gateway compatibility audience = os.environ.get( "CLOUD_RUN_AUDIENCE", - "https://jetpacker-server-254502043090.us-central1.run.app", + "https://YOUR_CLOUD_RUN_SERVICE_URL", + ) + url = ( + "http://metadata.google.internal/computeMetadata/v1/instance/" + f"service-accounts/default/identity?audience={audience}" ) - url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}" try: req = urllib.request.Request(url) req.add_header("Metadata-Flavor", "Google") - token = urllib.request.urlopen(req).read().decode("utf-8") + token = urllib.request.urlopen(req, timeout=1.5).read().decode("utf-8") return {"token": token, "url": f"{audience}/run_sse"} except Exception as e: # If metadata server fails or running unauthenticated/local, provide fallback diff --git a/jetpacker/server/openapi.yaml b/jetpacker/server/openapi.yaml index f31c6adb..e3626e26 100644 --- a/jetpacker/server/openapi.yaml +++ b/jetpacker/server/openapi.yaml @@ -11,9 +11,9 @@ securityDefinitions: authorizationUrl: "" flow: "implicit" type: "oauth2" - x-google-issuer: "https://securetoken.google.com/android-devrel-ci" + x-google-issuer: "https://securetoken.google.com/YOUR_FIREBASE_PROJECT_ID" x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com" - x-google-audiences: "android-devrel-ci" + x-google-audiences: "YOUR_FIREBASE_PROJECT_ID" security: - firebase: [] @@ -24,7 +24,7 @@ paths: summary: Orchestrate operationId: orchestrate x-google-backend: - address: https://jetpacker-server-254502043090.us-central1.run.app/orchestrate + address: https://YOUR_CLOUD_RUN_SERVICE_URL/orchestrate deadline: 300.0 responses: '200': @@ -34,7 +34,7 @@ paths: summary: Stream operationId: stream x-google-backend: - address: https://jetpacker-server-254502043090.us-central1.run.app/stream + address: https://YOUR_CLOUD_RUN_SERVICE_URL/stream deadline: 300.0 responses: '200': @@ -44,7 +44,7 @@ paths: summary: Respond operationId: respond x-google-backend: - address: https://jetpacker-server-254502043090.us-central1.run.app/respond + address: https://YOUR_CLOUD_RUN_SERVICE_URL/respond deadline: 300.0 responses: '200': @@ -54,7 +54,7 @@ paths: summary: Status Stream operationId: status_stream x-google-backend: - address: https://jetpacker-server-254502043090.us-central1.run.app/status_stream + address: https://YOUR_CLOUD_RUN_SERVICE_URL/status_stream deadline: 300.0 responses: '200': @@ -64,7 +64,7 @@ paths: summary: Orchestrate V2 operationId: orchestrate_v2 x-google-backend: - address: https://jetpacker-server-v2-254502043090.us-central1.run.app/orchestrate + address: https://YOUR_CLOUD_RUN_SERVICE_URL/v2/orchestrate deadline: 300.0 responses: '200': @@ -74,7 +74,7 @@ paths: summary: Stream V2 operationId: stream_v2 x-google-backend: - address: https://jetpacker-server-v2-254502043090.us-central1.run.app/stream + address: https://YOUR_CLOUD_RUN_SERVICE_URL/v2/stream deadline: 300.0 responses: '200': @@ -84,7 +84,7 @@ paths: summary: Respond V2 operationId: respond_v2 x-google-backend: - address: https://jetpacker-server-v2-254502043090.us-central1.run.app/respond + address: https://YOUR_CLOUD_RUN_SERVICE_URL/v2/respond deadline: 300.0 responses: '200': @@ -94,7 +94,7 @@ paths: summary: Status Stream V2 operationId: status_stream_v2 x-google-backend: - address: https://jetpacker-server-v2-254502043090.us-central1.run.app/status_stream + address: https://YOUR_CLOUD_RUN_SERVICE_URL/v2/status_stream deadline: 300.0 responses: '200': diff --git a/jetpacker/server/schemas.py b/jetpacker/server/schemas.py new file mode 100644 index 00000000..c0adcd90 --- /dev/null +++ b/jetpacker/server/schemas.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any +import pydantic + + +class FlightOptionSchema(pydantic.BaseModel): + id: str + flightNumber: str + departureTime: str + arrivalTime: str + duration: str + fare: int + badge: str + aircraft: str + + +class FlightConfigProposal(pydantic.BaseModel): + originCode: str + originCity: str + destCode: str + destCity: str + airline: str + flightNumber: str + aircraftName: str + duration: str + baseFare: int + gate: str + terminal: str + boardingGroup: str + rationale: str + occupiedSeats: list[str] + contestedSeat: str + options: list[FlightOptionSchema] + + +class HotelRoomOptionSchema(pydantic.BaseModel): + id: str + name: str + amenities: str + rate: int + badge: str + + +class HotelConfigProposal(pydantic.BaseModel): + hotelName: str + locationLabel: str + checkInDate: str + checkOutDate: str + nights: int + rationale: str + liveUpdateAlert: str + roomNumber: str + confirmationCode: str + suites: list[HotelRoomOptionSchema] + + +class ActivityOrDiningProposal(pydantic.BaseModel): + prompt: str + options: list[str] + holdConfirmationPrompt: str + confirmedDescription: str + + +class ResponseData(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="allow") + + seat: str | None = None + selectedSeat: str | None = None + time: str | None = None + selectedOption: str | None = None + selectedFlightId: str | None = None + selectedRoomId: str | None = None + checkInTime: str | None = None + floorPreference: str | None = None + breakfastIncluded: bool | None = None + lateCheckout: bool | None = None + confirmed: bool | None = None + selectionType: str | None = None + priorityBoarding: bool | None = None + extraBag: bool | None = None + totalFare: int | None = None + clientDataModel: dict[str, Any] | None = None From 2831525fb2b1c9a9c0e14f67b6da02c1d7ac3dc1 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Mon, 26 Oct 2026 14:21:28 +0000 Subject: [PATCH 2/2] fix(booking_assistant): wrap A2UI action events and initialize ADK session before SSE stream --- .../BookingAssistantViewModel.kt | 31 +++++++++++++++++-- .../catalog/CatalogActions.kt | 25 +++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt index 636aebcf..1698b949 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt @@ -368,6 +368,21 @@ constructor( activeStreamCall?.cancel() activeStreamJob = viewModelScope.launch(Dispatchers.IO) { + val serverBase = su.removeSuffix("/run_sse") + runCatching { + val createSessionReq = + Request.Builder() + .url("$serverBase/apps/booking/users/user_id/sessions/$sId") + .apply { + if (st.isNotEmpty()) addHeader("Authorization", "Bearer $st") + } + .post("{}".toRequestBody("application/json".toMediaType())) + .build() + client.newCall(createSessionReq).execute().close() + }.onFailure { + log("Create session error: ${it.message}") + } + val call = client.newCall(sseRequest) activeStreamCall = call try { @@ -452,8 +467,20 @@ constructor( } val directives = extractA2uiDirectives(text) - for (directive in directives) { - messageProcessor.processInput(a2uiParser, directive) + if (directives.isNotEmpty()) { + for (directive in directives) { + messageProcessor.processInput(a2uiParser, directive) + } + } else if (author != "__assistant__" && !text.contains("\"chatMessage\"") && text.isNotEmpty()) { + val msgId = + "assistant_${System.currentTimeMillis()}_${_uiState.value.chatMessages.size}" + _uiState.update { state -> + state.copy( + chatMessages = + state.chatMessages + + BookingChatMessage(id = msgId, isUser = false, text = text), + ) + } } } } else { diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt index c3858dc0..a9cf318e 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/catalog/CatalogActions.kt @@ -8,25 +8,38 @@ import androidx.a2ui.compose.runtime.A2uiComponentScope /** * Dispatches an action merging context parameters with explicit payload values. + * Wraps the payload in an A2UI 'event' schema matching [A2uiUserActionParser]. */ internal fun A2uiComponentScope.dispatchMergedAction( action: Map?, defaultEventName: String, extraContext: Map, ) { - val eventPayload = HashMap(extraContext) + val mergedContext = HashMap(extraContext) + val eventName: String if (action != null) { val eventMap = (action["event"] as? Map<*, *>) ?: action val contextMap = (eventMap["context"] as? Map<*, *>) ?: (action["context"] as? Map<*, *>) contextMap?.forEach { (k, v) -> - if (k is String && v != null) eventPayload[k] = v + if (k is String && v != null) mergedContext[k] = v } - val eventName = (eventMap["name"] as? String) + eventName = (eventMap["name"] as? String) + ?: (action["name"] as? String) ?: (action["eventName"] as? String) ?: defaultEventName - eventPayload["eventName"] = eventName } else { - eventPayload["eventName"] = defaultEventName + eventName = defaultEventName } - dispatchAction(eventPayload) + + // Preserve event name in context map as well for compatibility with consumers + mergedContext["name"] = eventName + mergedContext["eventName"] = eventName + + val actionPayload = mapOf( + "event" to mapOf( + "name" to eventName, + "context" to mergedContext, + ) + ) + dispatchAction(actionPayload) }