diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/DataStoreSettingsRepository.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/DataStoreSettingsRepository.kt index aac24d4..faf6699 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/data/DataStoreSettingsRepository.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/DataStoreSettingsRepository.kt @@ -17,6 +17,7 @@ package com.example.appfunctions.agent.data import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey @@ -39,6 +40,7 @@ class DataStoreSettingsRepository val SERVICE_TIER = stringPreferencesKey("service_tier") val PINNED_APPS = stringSetPreferencesKey("pinned_apps") val DISCONNECTED_APPS = stringSetPreferencesKey("disconnected_apps") + val APP_FUNCTION_DEBUGGING = booleanPreferencesKey("app_function_debugging") } override val geminiApiKey: Flow = @@ -73,6 +75,11 @@ class DataStoreSettingsRepository preferences[PreferencesKeys.DISCONNECTED_APPS] ?: emptySet() } + override val appFunctionDebuggingEnabled: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.APP_FUNCTION_DEBUGGING] ?: true + } + override suspend fun setGeminiApiKey(apiKey: String) { dataStore.edit { preferences -> preferences[PreferencesKeys.GEMINI_API_KEY] = apiKey } } @@ -118,4 +125,10 @@ class DataStoreSettingsRepository preferences[PreferencesKeys.DISCONNECTED_APPS] = newDisconnected } } + + override suspend fun setAppFunctionDebuggingEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.APP_FUNCTION_DEBUGGING] = enabled + } + } } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/SettingsRepository.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/SettingsRepository.kt index 0feefb9..8cfe0b1 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/data/SettingsRepository.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/SettingsRepository.kt @@ -54,4 +54,10 @@ interface SettingsRepository { packageName: String, connected: Boolean, ) + + /** Flow of AppFunction debugging setting. */ + val appFunctionDebuggingEnabled: Flow + + /** Sets whether AppFunction debugging is enabled. */ + suspend fun setAppFunctionDebuggingEnabled(enabled: Boolean) } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/AgentDemoScreenLayout.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/AgentDemoScreenLayout.kt new file mode 100644 index 0000000..e222594 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/AgentDemoScreenLayout.kt @@ -0,0 +1,35 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.contracts + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.appfunctions.agent.ui.screens.agentdemo.AgentUiEvent +import com.example.appfunctions.agent.ui.screens.agentdemo.AgentUiState + +/** + * Mandatory interface contract for Agent Demo screen layouts. + * Both Mobile and TV implementations MUST conform to this contract. + */ +interface AgentDemoScreenLayout { + @Composable + fun Content( + uiState: AgentUiState, + onEvent: (AgentUiEvent) -> Unit, + initialSidePanelVisible: Boolean, + modifier: Modifier, + ) +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/ConnectedAppsScreenLayout.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/ConnectedAppsScreenLayout.kt new file mode 100644 index 0000000..5f8b52d --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/ConnectedAppsScreenLayout.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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.appfunctions.agent.ui.contracts + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.appfunctions.agent.ui.screens.agentdemo.ConnectedAppsUiState + +/** + * Mandatory interface contract for Connected Apps screen layouts. + * Both Mobile and TV implementations MUST conform to this contract. + */ +interface ConnectedAppsScreenLayout { + @Composable + fun Content( + uiState: ConnectedAppsUiState, + onBack: () -> Unit, + onToggleApp: (String, Boolean) -> Unit, + modifier: Modifier, + ) +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/DebuggingScreenLayout.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/DebuggingScreenLayout.kt new file mode 100644 index 0000000..a21894b --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/DebuggingScreenLayout.kt @@ -0,0 +1,44 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.contracts + +import android.app.PendingIntent +import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.appfunctions.agent.domain.appfunction.AppInfo +import com.example.appfunctions.agent.ui.screens.debugging.DebuggingUiState + +/** + * Mandatory interface contract for Debugging screen layouts. + * Both Mobile and TV implementations MUST conform to this contract. + */ +interface DebuggingScreenLayout { + @Composable + fun Content( + uiState: DebuggingUiState, + onSearchQueryChanged: (String) -> Unit, + onAppSelected: (AppInfo) -> Unit, + onClearSelectedApp: () -> Unit, + onFunctionInputsChange: (String, Map) -> Unit, + onInvoke: (AppFunctionMetadata) -> Unit, + onClearResult: () -> Unit, + onFunctionExpandedChange: (String, Boolean) -> Unit, + onLaunchPendingIntent: (PendingIntent) -> Unit, + onTogglePin: (AppInfo) -> Unit, + modifier: Modifier, + ) +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/SettingsScreenLayout.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/SettingsScreenLayout.kt new file mode 100644 index 0000000..103c772 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/SettingsScreenLayout.kt @@ -0,0 +1,37 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.contracts + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.appfunctions.agent.data.ServiceTier + +/** + * Mandatory interface contract for Settings screen layouts. + * Both Mobile and TV implementations MUST conform to this contract. + */ +interface SettingsScreenLayout { + @Composable + fun Content( + geminiApiKeyState: TextFieldState, + serviceTier: ServiceTier, + onServiceTierSelected: (ServiceTier) -> Unit, + onOpenLicenses: () -> Unit, + onNavigateToConnectedApps: () -> Unit, + modifier: Modifier, + ) +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/FormFactorUtils.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/FormFactorUtils.kt new file mode 100644 index 0000000..6f4cd21 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/FormFactorUtils.kt @@ -0,0 +1,47 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.layout + +import android.content.pm.PackageManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +enum class FormFactor { + MOBILE, + TV, + WEAR, + AUTO, + XR, +} + +@Composable +fun rememberFormFactor(): FormFactor { + val context = LocalContext.current + return remember(context) { + val pm = context.packageManager + when { + pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) -> FormFactor.TV + pm.hasSystemFeature(PackageManager.FEATURE_WATCH) -> FormFactor.WEAR + pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) -> FormFactor.AUTO + pm.hasSystemFeature("android.hardware.xr.display") -> FormFactor.XR + else -> FormFactor.MOBILE + } + } +} + +@Composable +fun isTvFormFactor(): Boolean = rememberFormFactor() == FormFactor.TV diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/ScreenLayoutFactory.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/ScreenLayoutFactory.kt new file mode 100644 index 0000000..115b5af --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/ScreenLayoutFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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.appfunctions.agent.ui.layout + +import com.example.appfunctions.agent.ui.contracts.AgentDemoScreenLayout +import com.example.appfunctions.agent.ui.contracts.ConnectedAppsScreenLayout +import com.example.appfunctions.agent.ui.contracts.DebuggingScreenLayout +import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout +import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileAgentDemoLayout +import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileConnectedAppsLayout +import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileSettingsLayout +import com.example.appfunctions.agent.ui.mobile.debugging.MobileDebuggingLayout + +/** + * Centralized layout factory that resolves screen layout contracts for specific form factors. + * This pattern decouples screen routers from explicit form factor resolution conditionals, + * allowing new device form factors (e.g., Wear OS, Android XR, Auto) to be added cleanly. + */ +fun FormFactor.resolveAgentDemoLayout(): AgentDemoScreenLayout = MobileAgentDemoLayout + +fun FormFactor.resolveConnectedAppsLayout(): ConnectedAppsScreenLayout = MobileConnectedAppsLayout + +fun FormFactor.resolveSettingsLayout(): SettingsScreenLayout = MobileSettingsLayout + +fun FormFactor.resolveDebuggingLayout(): DebuggingScreenLayout = MobileDebuggingLayout diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileAgentDemoContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileAgentDemoContent.kt new file mode 100644 index 0000000..3e75740 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileAgentDemoContent.kt @@ -0,0 +1,740 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.mobile.agentdemo + +import android.content.pm.PackageManager +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.core.graphics.drawable.toBitmap +import com.example.appfunctions.agent.R +import com.example.appfunctions.agent.data.LlmModel +import com.example.appfunctions.agent.data.db.entities.ThreadEntity +import com.example.appfunctions.agent.domain.AgentStatus +import com.example.appfunctions.agent.ui.contracts.AgentDemoScreenLayout +import com.example.appfunctions.agent.ui.screens.agentdemo.AgentDemoLoadingScreen +import com.example.appfunctions.agent.ui.screens.agentdemo.AgentUiEvent +import com.example.appfunctions.agent.ui.screens.agentdemo.AgentUiState +import com.example.appfunctions.agent.ui.screens.agentdemo.ChatHistorySidePanel +import com.example.appfunctions.agent.ui.screens.agentdemo.MessageBubble +import com.example.appfunctions.agent.ui.screens.agentdemo.StatusIndicator +import com.example.appfunctions.agent.ui.screens.debugging.LazyExposedDropdownMenu +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +object MobileAgentDemoLayout : AgentDemoScreenLayout { + @Composable + override fun Content( + uiState: AgentUiState, + onEvent: (AgentUiEvent) -> Unit, + initialSidePanelVisible: Boolean, + modifier: Modifier, + ) { + MobileAgentDemoContent( + uiState = uiState, + onEvent = onEvent, + initialSidePanelVisible = initialSidePanelVisible, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileAgentDemoContent( + uiState: AgentUiState, + onEvent: (AgentUiEvent) -> Unit, + initialSidePanelVisible: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val packageManager = context.packageManager + val focusManager = LocalFocusManager.current + + val containerSize = LocalConfiguration.current.screenWidthDp + val isWideScreen = containerSize >= 600 + + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { focusManager.clearFocus() } + + val content = + @Composable { + when (uiState) { + is AgentUiState.Loading -> { + AgentDemoLoadingScreen() + } + + is AgentUiState.Loaded -> { + MobileAgentDemoLoadedScreen( + uiState = uiState, + onEvent = onEvent, + isWideScreen = isWideScreen, + drawerState = drawerState, + scope = scope, + packageManager = packageManager, + initialSidePanelVisible = initialSidePanelVisible, + modifier = modifier, + ) + } + } + } + + if (isWideScreen) { + content() + } else { + val currentThread = (uiState as? AgentUiState.Loaded)?.currentThread + val threads = (uiState as? AgentUiState.Loaded)?.threads ?: emptyList() + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerContainerColor = MaterialTheme.colorScheme.surface, + ) { + ChatHistorySidePanel( + threads = threads, + currentThread = currentThread, + onEvent = { event -> + onEvent(event) + scope.launch { drawerState.close() } + }, + ) + } + }, + ) { + content() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileAgentDemoLoadedScreen( + uiState: AgentUiState.Loaded, + onEvent: (AgentUiEvent) -> Unit, + isWideScreen: Boolean, + drawerState: DrawerState, + scope: CoroutineScope, + packageManager: PackageManager, + modifier: Modifier = Modifier, + initialSidePanelVisible: Boolean = false, +) { + var messageText by remember { mutableStateOf(TextFieldValue("")) } + var isSidePanelVisible by remember { mutableStateOf(initialSidePanelVisible) } + var selectedAppPackageName by remember { mutableStateOf(null) } + + val inputFocusRequester = remember { FocusRequester() } + val listState = rememberLazyListState() + val currentThreadId = uiState.currentThread.threadId + var hasInitiallyScrolled by remember(currentThreadId) { mutableStateOf(false) } + + LaunchedEffect(uiState.messages, currentThreadId) { + if (uiState.messages.isNotEmpty() && !hasInitiallyScrolled) { + hasInitiallyScrolled = true + listState.scrollToItem(0) + } + } + + LaunchedEffect(uiState.messages.size) { + if (uiState.messages.isNotEmpty()) { + listState.animateScrollToItem(0) + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Unspecified, + topBar = { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MobileModelDropdown( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 8.dp), + currentThread = uiState.currentThread, + onModelSelected = { onEvent(AgentUiEvent.OnModelSelected(it)) }, + isAppFunctionDebuggingEnabled = uiState.isAppFunctionDebuggingEnabled, + onToggleAppFunctionDebugging = { + onEvent(AgentUiEvent.OnToggleAppFunctionDebugging(it)) + }, + onMenuClick = { + if (isWideScreen) { + isSidePanelVisible = !isSidePanelVisible + } else { + scope.launch { drawerState.open() } + } + }, + ) + IconButton( + onClick = { + onEvent(AgentUiEvent.OnCreateThread(uiState.currentThread.llmModel)) + }, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Create Thread") + } + } + }, + ) { paddingValues -> + Row( + modifier = + Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()) + .consumeWindowInsets(paddingValues) + .imePadding(), + ) { + if (isWideScreen) { + AnimatedVisibility( + visible = isSidePanelVisible, + enter = slideInHorizontally() + expandHorizontally(), + exit = slideOutHorizontally() + shrinkHorizontally(), + ) { + ChatHistorySidePanel( + threads = uiState.threads, + currentThread = uiState.currentThread, + onEvent = onEvent, + ) + } + } + + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 16.dp, end = 16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + LazyColumn( + state = listState, + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + reverseLayout = true, + ) { + if (uiState.status != AgentStatus.Idle) { + item { + StatusIndicator( + status = uiState.status, + packageManager = packageManager, + ) + } + } + + items( + items = uiState.messages.reversed(), + key = { message -> message.messageId }, + ) { message -> + MessageBubble( + message = message, + isValidAction = + message.pendingIntentId in uiState.activePendingActionIds, + installedApps = uiState.installedApps, + showAppFunctionDebugDetails = uiState.isAppFunctionDebuggingEnabled, + onConfirmAction = { onEvent(AgentUiEvent.OnConfirmAction(it)) }, + ) + } + } + + val sendMessage = { + val textStr = messageText.text + if (textStr.isNotBlank()) { + onEvent(AgentUiEvent.OnSendMessage(textStr, selectedAppPackageName)) + messageText = TextFieldValue("") + selectedAppPackageName = null + inputFocusRequester.requestFocus() + scope.launch { + listState.animateScrollToItem(0) + } + } + } + + val textStr = messageText.text + val lastAtIndex = textStr.lastIndexOf('@') + val showAutocomplete = + lastAtIndex >= 0 && + (lastAtIndex == 0 || textStr[lastAtIndex - 1].isWhitespace()) && + selectedAppPackageName == null + val autocompleteQuery = + if (showAutocomplete) { + textStr.substring(lastAtIndex + 1) + } else { + "" + } + val filteredApps = + remember(autocompleteQuery, uiState.installedApps) { + if (autocompleteQuery.isEmpty()) { + uiState.installedApps + } else { + uiState.installedApps.filter { + it.label.contains(autocompleteQuery, ignoreCase = true) + } + } + } + + val density = LocalDensity.current + val popupPositionProvider = + remember(density) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val gap = with(density) { 2.dp.roundToPx() } + return IntOffset( + x = anchorBounds.left, + y = anchorBounds.top - popupContentSize.height - gap, + ) + } + } + } + + val appMentionRegex = + remember(uiState.installedApps) { + if (uiState.installedApps.isNotEmpty()) { + val appLabelsPattern = + uiState.installedApps.joinToString("|") { Regex.escape(it.label) } + Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE) + } else { + null + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + ) { + if (showAutocomplete && filteredApps.isNotEmpty()) { + Popup( + popupPositionProvider = popupPositionProvider, + onDismissRequest = {}, + ) { + Card( + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + LazyColumn( + modifier = + Modifier + .height( + if (filteredApps.size > 3) 200.dp else androidx.compose.ui.unit.Dp.Unspecified, + ), + ) { + items(filteredApps) { app -> + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + app.icon?.let { + Image( + bitmap = it.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = app.label) + } + }, + onClick = { + val prefix = textStr.substring(0, lastAtIndex) + val newText = "$prefix@${app.label} " + messageText = + TextFieldValue( + text = newText, + selection = + androidx.compose.ui.text.TextRange( + newText.length, + ), + ) + selectedAppPackageName = app.packageName + }, + ) + } + } + } + } + } + + val primaryColor = MaterialTheme.colorScheme.primary + val styledTextFieldValue = + remember(messageText, appMentionRegex, primaryColor) { + if (appMentionRegex != null) { + val annotatedString = + buildAnnotatedString { + var lastIndex = 0 + appMentionRegex.findAll(messageText.text).forEach { matchResult -> + append(messageText.text.substring(lastIndex, matchResult.range.first)) + pushStyle( + SpanStyle( + color = primaryColor, + fontWeight = FontWeight.Bold, + ), + ) + append(matchResult.value) + pop() + lastIndex = matchResult.range.last + 1 + } + if (lastIndex < messageText.text.length) { + append(messageText.text.substring(lastIndex)) + } + } + messageText.copy(annotatedString = annotatedString) + } else { + messageText + } + } + + OutlinedTextField( + value = styledTextFieldValue, + onValueChange = { newValue -> + messageText = newValue + if (selectedAppPackageName != null) { + val currentMention = "@" + if (!newValue.text.contains(currentMention)) { + selectedAppPackageName = null + } + } + }, + placeholder = { Text(stringResource(R.string.agent_demo_ask_agent)) }, + leadingIcon = + if (selectedAppPackageName != null) { + val pkg = selectedAppPackageName!! + val app = uiState.installedApps.find { it.packageName == pkg } + val iconComposable: @Composable () -> Unit = { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.padding(start = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + app?.icon?.let { + Image( + bitmap = it.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = app?.label ?: pkg, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { selectedAppPackageName = null }, + modifier = Modifier.size(16.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove app target", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } + iconComposable + } else { + null + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(inputFocusRequester) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown && + (keyEvent.key == Key.Enter || keyEvent.key == Key.NumPadEnter) + ) { + if (keyEvent.isCtrlPressed || keyEvent.isShiftPressed) { + val selection = messageText.selection + val text = messageText.text + val newText = + text.substring(0, selection.min) + "\n" + text.substring(selection.max) + val newSelection = TextRange(selection.min + 1) + messageText = + messageText.copy( + text = newText, + selection = newSelection, + ) + true + } else { + sendMessage() + true + } + } else { + false + } + }, + maxLines = 5, + shape = RoundedCornerShape(24.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendMessage() }), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { sendMessage() }, + ) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.agent_demo_send), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileModelDropdown( + modifier: Modifier = Modifier, + currentThread: ThreadEntity?, + onModelSelected: (LlmModel) -> Unit, + isAppFunctionDebuggingEnabled: Boolean = true, + onToggleAppFunctionDebugging: (Boolean) -> Unit = {}, + onMenuClick: (() -> Unit)? = null, +) { + val models = + listOf( + LlmModel.GEMINI_3_1_PRO_PREVIEW, + LlmModel.GEMINI_3_FLASH_PREVIEW, + LlmModel.GEMINI_3_1_FLASH_LITE_PREVIEW, + ) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + Surface( + modifier = Modifier.padding(bottom = 8.dp), + shadowElevation = 2.dp, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceBright, + ) { + val text = + currentThread?.llmModel?.modelName + ?: stringResource(R.string.agent_demo_select_model_to_create_thread) + val textColor = + if (currentThread != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.error + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(56.dp) + .padding(start = 4.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onMenuClick != null) { + IconButton(onClick = onMenuClick) { + Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu") + } + } else { + Spacer(modifier = Modifier.width(12.dp)) + } + Row( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryEditable, + enabled = true, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.agent_demo_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null) + } + } + } + + LazyExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.exposedDropdownSize(), + containerColor = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(28.dp), + ) { + item { + Text( + "--- Gemini ---", + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + items(models) { model -> + DropdownMenuItem( + text = { Text(model.modelName) }, + onClick = { + onModelSelected(model) + expanded = false + }, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + ) + } + } + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileConnectedAppsContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileConnectedAppsContent.kt new file mode 100644 index 0000000..313d9a6 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileConnectedAppsContent.kt @@ -0,0 +1,155 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.mobile.agentdemo + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import com.example.appfunctions.agent.R +import com.example.appfunctions.agent.ui.contracts.ConnectedAppsScreenLayout +import com.example.appfunctions.agent.ui.screens.agentdemo.ConnectedAppsUiState + +object MobileConnectedAppsLayout : ConnectedAppsScreenLayout { + @Composable + override fun Content( + uiState: ConnectedAppsUiState, + onBack: () -> Unit, + onToggleApp: (String, Boolean) -> Unit, + modifier: Modifier, + ) { + MobileConnectedAppsContent( + uiState = uiState, + onBack = onBack, + onToggleApp = onToggleApp, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileConnectedAppsContent( + uiState: ConnectedAppsUiState, + onBack: () -> Unit, + onToggleApp: (String, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Unspecified, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.connected_apps_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .padding(horizontal = 24.dp), + ) { + LazyColumn { + items( + items = uiState.connectedApps, + key = { app -> app.packageName }, + ) { app -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + if (app.icon != null) { + Image( + painter = rememberAsyncImagePainter(app.icon), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + } else { + Box(modifier = Modifier.size(40.dp).background(Color.Gray)) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.label, + style = MaterialTheme.typography.bodyLarge, + ) + if (!app.description.isNullOrEmpty()) { + Text( + text = app.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = app.isConnected, + onCheckedChange = { connected -> + onToggleApp(app.packageName, connected) + }, + ) + } + } + } + } + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileSettingsContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileSettingsContent.kt new file mode 100644 index 0000000..4af9d21 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileSettingsContent.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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.appfunctions.agent.ui.mobile.agentdemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.example.appfunctions.agent.BuildConfig +import com.example.appfunctions.agent.R +import com.example.appfunctions.agent.data.ServiceTier +import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout + +object MobileSettingsLayout : SettingsScreenLayout { + @Composable + override fun Content( + geminiApiKeyState: TextFieldState, + serviceTier: ServiceTier, + onServiceTierSelected: (ServiceTier) -> Unit, + onOpenLicenses: () -> Unit, + onNavigateToConnectedApps: () -> Unit, + modifier: Modifier, + ) { + MobileSettingsContent( + geminiApiKeyState = geminiApiKeyState, + serviceTier = serviceTier, + onServiceTierSelected = onServiceTierSelected, + onOpenLicenses = onOpenLicenses, + onNavigateToConnectedApps = onNavigateToConnectedApps, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileSettingsContent( + geminiApiKeyState: TextFieldState, + serviceTier: ServiceTier, + onServiceTierSelected: (ServiceTier) -> Unit, + onOpenLicenses: () -> Unit, + onNavigateToConnectedApps: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Unspecified, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.nav_settings), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(id = R.string.settings_agent), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp).semantics { heading() }, + ) + } + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(id = R.string.settings_gemini_api_key), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (BuildConfig.IS_RETAIL) { + OutlinedTextField( + value = geminiApiKeyState.text.toString(), + onValueChange = {}, + placeholder = { Text(stringResource(id = R.string.settings_gemini_api_key)) }, + visualTransformation = PasswordVisualTransformation(), + colors = + OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + ), + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } else { + OutlinedTextField( + value = geminiApiKeyState.text.toString(), + onValueChange = { geminiApiKeyState.setTextAndPlaceCursorAtEnd(it) }, + placeholder = { Text(stringResource(id = R.string.settings_gemini_api_key)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(id = R.string.settings_service_tier), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + com.example.appfunctions.agent.ui.screens.agentdemo.ServiceTierDropdown( + selectedTier = serviceTier, + onTierSelected = onServiceTierSelected, + ) + Text( + text = stringResource(id = R.string.settings_service_tier_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.manage_connected_apps), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon(imageVector = Icons.Default.Apps, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = + Modifier.clickable(role = Role.Button) { onNavigateToConnectedApps() }, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(id = R.string.settings_about), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp).semantics { heading() }, + ) + } + + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.settings_open_source_licenses), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon(imageVector = Icons.Default.Info, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(role = Role.Button) { onOpenLicenses() }, + ) + } + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/debugging/MobileDebuggingContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/debugging/MobileDebuggingContent.kt new file mode 100644 index 0000000..545c61b --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/debugging/MobileDebuggingContent.kt @@ -0,0 +1,482 @@ +/* + * 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 + * + * https://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.appfunctions.agent.ui.mobile.debugging + +import android.app.PendingIntent +import android.content.res.Resources +import androidx.activity.compose.BackHandler +import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.example.appfunctions.agent.R +import com.example.appfunctions.agent.domain.appfunction.AppInfo +import com.example.appfunctions.agent.ui.contracts.DebuggingScreenLayout +import com.example.appfunctions.agent.ui.screens.debugging.AppsGroupState +import com.example.appfunctions.agent.ui.screens.debugging.DebuggingUiState +import com.example.appfunctions.agent.ui.screens.debugging.FunctionsFoundContent +import com.example.appfunctions.agent.ui.screens.debugging.SearchAppResultState +import com.example.appfunctions.agent.ui.screens.debugging.TroubleshootResult + +object MobileDebuggingLayout : DebuggingScreenLayout { + @Composable + override fun Content( + uiState: DebuggingUiState, + onSearchQueryChanged: (String) -> Unit, + onAppSelected: (AppInfo) -> Unit, + onClearSelectedApp: () -> Unit, + onFunctionInputsChange: (String, Map) -> Unit, + onInvoke: (AppFunctionMetadata) -> Unit, + onClearResult: () -> Unit, + onFunctionExpandedChange: (String, Boolean) -> Unit, + onLaunchPendingIntent: (PendingIntent) -> Unit, + onTogglePin: (AppInfo) -> Unit, + modifier: Modifier, + ) { + MobileDebuggingContent( + uiState = uiState, + onSearchQueryChanged = onSearchQueryChanged, + onAppSelected = onAppSelected, + onClearSelectedApp = onClearSelectedApp, + onFunctionInputsChange = onFunctionInputsChange, + onInvoke = onInvoke, + onClearResult = onClearResult, + onFunctionExpandedChange = onFunctionExpandedChange, + onLaunchPendingIntent = onLaunchPendingIntent, + onTogglePin = onTogglePin, + modifier = modifier, + ) + } +} + +@Composable +fun MobileDebuggingContent( + uiState: DebuggingUiState, + onSearchQueryChanged: (String) -> Unit, + onAppSelected: (AppInfo) -> Unit, + onClearSelectedApp: () -> Unit, + onFunctionInputsChange: (String, Map) -> Unit, + onInvoke: (AppFunctionMetadata) -> Unit, + onClearResult: () -> Unit, + onFunctionExpandedChange: (String, Boolean) -> Unit, + onLaunchPendingIntent: (PendingIntent) -> Unit, + onTogglePin: (AppInfo) -> Unit, + modifier: Modifier = Modifier, +) { + if (uiState.selectedApp == null) { + // STEP 1: App List Screen + MobileAppListScreen( + appGroups = uiState.filteredApps, + searchQuery = uiState.searchQuery, + isLoading = uiState.isLoading, + onSearchQueryChanged = onSearchQueryChanged, + onAppSelected = onAppSelected, + onTogglePin = onTogglePin, + modifier = modifier, + ) + } else { + BackHandler { + onClearSelectedApp() + } + + // STEP 2: App Functions Detail Screen + MobileFunctionsDetailScreen( + selectedApp = uiState.selectedApp, + searchAppResultState = uiState.searchAppResultState, + uiState = uiState, + onBack = onClearSelectedApp, + onFunctionInputsChange = onFunctionInputsChange, + onInvoke = onInvoke, + onClearResult = onClearResult, + onFunctionExpandedChange = onFunctionExpandedChange, + onLaunchPendingIntent = onLaunchPendingIntent, + onTogglePin = onTogglePin, + modifier = modifier, + ) + } +} + +/** Step 1: Full-Screen Installed Apps List on Mobile */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MobileAppListScreen( + appGroups: AppsGroupState, + searchQuery: String, + isLoading: Boolean, + onSearchQueryChanged: (String) -> Unit, + onAppSelected: (AppInfo) -> Unit, + onTogglePin: (AppInfo) -> Unit, + modifier: Modifier = Modifier, +) { + var isSearchExpanded by remember { mutableStateOf(searchQuery.isNotEmpty()) } + val sections = appGroups.sections + val pinnedPackageNames = + remember(sections) { + sections + .find { it.titleRes == Resources.ID_NULL } + ?.apps + ?.map { it.packageName } + ?.toSet() ?: emptySet() + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Unspecified, + topBar = { + TopAppBar( + title = { + if (isSearchExpanded) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChanged, + placeholder = { Text(stringResource(R.string.debugging_search_app)) }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + if (searchQuery.isNotEmpty()) { + onSearchQueryChanged("") + } else { + isSearchExpanded = false + } + }, + ) { + Icon(Icons.Filled.Clear, contentDescription = "Close search") + } + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + ), + shape = CircleShape, + modifier = + Modifier + .fillMaxWidth() + .padding(end = 8.dp), + ) + } else { + Text( + text = stringResource(id = R.string.debugging_installed_apps_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + }, + actions = { + if (!isSearchExpanded) { + IconButton(onClick = { isSearchExpanded = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.debugging_search_app), + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + ) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (sections.all { it.apps.isEmpty() }) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.debugging_select_app_prompt), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(bottom = 24.dp), + modifier = Modifier.fillMaxSize(), + ) { + sections.forEach { section -> + if (section.apps.isNotEmpty()) { + item { + if (section.titleRes != Resources.ID_NULL) { + Column( + modifier = + Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 4.dp, + ), + ) { + Text( + text = stringResource(section.titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider( + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + } + items(section.apps, key = { app -> "${section.titleRes}_${app.packageName}" }) { app -> + val isPinned = pinnedPackageNames.contains(app.packageName) + ListItem( + headlineContent = { + Text( + text = app.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + supportingContent = { + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = + if (app.icon != null) { + { + Image( + bitmap = app.icon.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } + } else { + null + }, + trailingContent = + if (section.showPin) { + { + IconButton(onClick = { onTogglePin(app) }) { + Icon( + imageVector = + if (isPinned) { + Icons.Filled.PushPin + } else { + Icons.Outlined.PushPin + }, + contentDescription = if (isPinned) "Unpin" else "Pin", + tint = + if (isPinned) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } else { + null + }, + modifier = + Modifier.clickable { + onSearchQueryChanged(app.label) + onAppSelected(app) + }, + ) + } + } + } + } + } + } + } +} + +/** Step 2: Functions Detail Screen for Selected App on Mobile */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MobileFunctionsDetailScreen( + selectedApp: AppInfo, + searchAppResultState: SearchAppResultState, + uiState: DebuggingUiState, + onBack: () -> Unit, + onFunctionInputsChange: (String, Map) -> Unit, + onInvoke: (AppFunctionMetadata) -> Unit, + onClearResult: () -> Unit, + onFunctionExpandedChange: (String, Boolean) -> Unit, + onLaunchPendingIntent: (PendingIntent) -> Unit, + onTogglePin: (AppInfo) -> Unit, + modifier: Modifier = Modifier, +) { + val isPinned = + remember(uiState.filteredApps, selectedApp.packageName) { + uiState.filteredApps.sections + .firstOrNull { it.titleRes == R.string.debugging_pinned_apps } + ?.apps + ?.any { it.packageName == selectedApp.packageName } == true + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Unspecified, + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to installed apps", + ) + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (selectedApp.icon != null) { + Image( + bitmap = selectedApp.icon.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + } + Column { + Text( + text = selectedApp.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = selectedApp.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + actions = { + IconButton(onClick = { onTogglePin(selectedApp) }) { + Icon( + imageVector = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, + contentDescription = if (isPinned) "Unpin App" else "Pin App", + tint = + if (isPinned) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + ) { + when (searchAppResultState) { + is SearchAppResultState.Idle -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + is SearchAppResultState.FunctionsFoundState -> { + FunctionsFoundContent( + state = searchAppResultState, + onFunctionExpandedChange = onFunctionExpandedChange, + onFunctionInputsChange = onFunctionInputsChange, + onInvoke = onInvoke, + onClearResult = onClearResult, + onLaunchPendingIntent = onLaunchPendingIntent, + ) + } + is SearchAppResultState.TroubleshootUiState -> { + TroubleshootResult( + state = searchAppResultState, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt index b1c9f3b..b6d64c9 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt @@ -16,24 +16,18 @@ package com.example.appfunctions.agent.ui.screens.agentdemo import android.content.pm.PackageManager -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable +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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -41,85 +35,36 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import androidx.core.graphics.drawable.toBitmap -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage import com.example.appfunctions.agent.R import com.example.appfunctions.agent.data.LlmModel import com.example.appfunctions.agent.data.db.entities.MessageEntity @@ -128,10 +73,9 @@ import com.example.appfunctions.agent.data.db.entities.MessageRole import com.example.appfunctions.agent.data.db.entities.ThreadEntity import com.example.appfunctions.agent.domain.AgentStatus import com.example.appfunctions.agent.domain.appfunction.AppInfo -import com.example.appfunctions.agent.ui.screens.debugging.LazyExposedDropdownMenu -import com.mikepenz.markdown.m3.Markdown -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import com.example.appfunctions.agent.ui.contracts.AgentDemoScreenLayout +import com.example.appfunctions.agent.ui.layout.rememberFormFactor +import com.example.appfunctions.agent.ui.layout.resolveAgentDemoLayout @Composable fun AgentDemoScreen(viewModel: AgentDemoViewModel = hiltViewModel()) { @@ -140,72 +84,20 @@ fun AgentDemoScreen(viewModel: AgentDemoViewModel = hiltViewModel()) { AgentDemoContent(uiState = uiState, onEvent = viewModel::onEvent) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentDemoContent( uiState: AgentUiState, onEvent: (AgentUiEvent) -> Unit, initialSidePanelVisible: Boolean = false, ) { - val context = LocalContext.current - val packageManager = context.packageManager - val focusManager = LocalFocusManager.current - - val containerSize = LocalConfiguration.current.screenWidthDp - val isWideScreen = containerSize >= 600 - - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { focusManager.clearFocus() } - - val content = - @Composable { - when (uiState) { - is AgentUiState.Loading -> { - AgentDemoLoadingScreen() - } - - is AgentUiState.Loaded -> { - AgentDemoLoadedScreen( - uiState = uiState, - onEvent = onEvent, - isWideScreen = isWideScreen, - drawerState = drawerState, - scope = scope, - packageManager = packageManager, - initialSidePanelVisible = initialSidePanelVisible, - ) - } - } - } - - if (isWideScreen) { - content() - } else { - val currentThread = (uiState as? AgentUiState.Loaded)?.currentThread - val threads = (uiState as? AgentUiState.Loaded)?.threads ?: emptyList() - - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - drawerContainerColor = MaterialTheme.colorScheme.surface, - ) { - ChatHistorySidePanel( - threads = threads, - currentThread = currentThread, - onEvent = { event -> - onEvent(event) - scope.launch { drawerState.close() } - }, - ) - } - }, - ) { - content() - } - } + val layout: AgentDemoScreenLayout = rememberFormFactor().resolveAgentDemoLayout() + + layout.Content( + uiState = uiState, + onEvent = onEvent, + initialSidePanelVisible = initialSidePanelVisible, + modifier = Modifier, + ) } @Composable @@ -215,423 +107,13 @@ fun AgentDemoLoadingScreen() { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AgentDemoLoadedScreen( - uiState: AgentUiState.Loaded, - onEvent: (AgentUiEvent) -> Unit, - isWideScreen: Boolean, - drawerState: DrawerState, - scope: CoroutineScope, - packageManager: PackageManager, - initialSidePanelVisible: Boolean = false, -) { - var messageText by remember { mutableStateOf(TextFieldValue("")) } - var isSidePanelVisible by remember { mutableStateOf(initialSidePanelVisible) } - var selectedAppPackageName by remember { mutableStateOf(null) } - - val chipBgColor = MaterialTheme.colorScheme.primaryContainer - val chipTextColor = MaterialTheme.colorScheme.onPrimaryContainer - val visualTransformation = - remember(uiState.installedApps, chipTextColor) { - InlineAppScopingVisualTransformation(uiState.installedApps, chipTextColor) - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Unspecified, - topBar = { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ModelDropdown( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 8.dp), - currentThread = uiState.currentThread, - onModelSelected = { onEvent(AgentUiEvent.OnModelSelected(it)) }, - onMenuClick = { - if (isWideScreen) { - isSidePanelVisible = !isSidePanelVisible - } else { - scope.launch { drawerState.open() } - } - }, - ) - IconButton( - onClick = { - onEvent(AgentUiEvent.OnCreateThread(uiState.currentThread.llmModel)) - }, - modifier = Modifier.padding(horizontal = 8.dp), - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Create Thread") - } - } - }, - ) { paddingValues -> - Row( - modifier = - Modifier - .fillMaxSize() - .imePadding() - .padding( - top = paddingValues.calculateTopPadding(), - ), - ) { - // Side Panel (only for wide screens) - if (isWideScreen) { - AnimatedVisibility( - visible = isSidePanelVisible, - enter = slideInHorizontally() + expandHorizontally(), - exit = slideOutHorizontally() + shrinkHorizontally(), - ) { - ChatHistorySidePanel( - threads = uiState.threads, - currentThread = uiState.currentThread, - onEvent = onEvent, - ) - } - } - - // Main Chat Area - Column( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .padding(start = 16.dp, end = 16.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - // Messages List - LazyColumn( - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)), - reverseLayout = true, - ) { - // Status item at the bottom (above input) if not - // idle - if (uiState.status != AgentStatus.Idle) { - item { - StatusIndicator( - status = uiState.status, - packageManager = packageManager, - ) - } - } - - items( - items = uiState.messages.reversed(), - key = { message -> message.messageId }, - ) { message -> - MessageBubble( - message = message, - isValidAction = - message.pendingIntentId in uiState.activePendingActionIds, - installedApps = uiState.installedApps, - onConfirmAction = { onEvent(AgentUiEvent.OnConfirmAction(it)) }, - ) - } - } - - val sendMessage = { - val textStr = messageText.text - if (textStr.isNotBlank() && uiState.status == AgentStatus.Idle) { - onEvent(AgentUiEvent.OnSendMessage(textStr, selectedAppPackageName)) - messageText = TextFieldValue("") - selectedAppPackageName = null - } - } - - val textStr = messageText.text - val lastAtIndex = textStr.lastIndexOf('@') - val showAutocomplete = - lastAtIndex >= 0 && - (lastAtIndex == 0 || textStr[lastAtIndex - 1].isWhitespace()) && - selectedAppPackageName == null - val autocompleteQuery = - if (showAutocomplete) { - textStr.substring(lastAtIndex + 1) - } else { - "" - } - val filteredApps = - remember(autocompleteQuery, uiState.installedApps) { - if (autocompleteQuery.isEmpty()) { - uiState.installedApps - } else { - uiState.installedApps.filter { - it.label.contains(autocompleteQuery, ignoreCase = true) - } - } - } - - val density = LocalDensity.current - val popupPositionProvider = - remember(density) { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val gap = with(density) { 2.dp.roundToPx() } - return IntOffset( - x = anchorBounds.left, - y = anchorBounds.top - popupContentSize.height - gap, - ) - } - } - } - - val appMentionRegex = - remember(uiState.installedApps) { - if (uiState.installedApps.isNotEmpty()) { - val appLabelsPattern = - uiState.installedApps.joinToString("|") { Regex.escape(it.label) } - Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE) - } else { - null - } - } - - // Input area - Box(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = messageText, - onValueChange = { newValue -> - messageText = newValue - val currentText = newValue.text - if (selectedAppPackageName != null && appMentionRegex != null) { - if (!appMentionRegex.containsMatchIn(currentText)) { - selectedAppPackageName = null - } - } - }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .onPreviewKeyEvent { keyEvent -> - if ( - (keyEvent.key == Key.Enter || keyEvent.key == Key.NumPadEnter) && - keyEvent.type == KeyEventType.KeyDown - ) { - sendMessage() - true - } else { - false - } - }, - enabled = uiState.status == AgentStatus.Idle, - shape = CircleShape, - placeholder = { Text(stringResource(R.string.agent_demo_ask_agent)) }, - visualTransformation = visualTransformation, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - trailingIcon = { - IconButton( - onClick = sendMessage, - enabled = - messageText.text.isNotBlank() && - uiState.status == AgentStatus.Idle, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = - stringResource(R.string.agent_demo_send), - ) - } - }, - ) - - if (showAutocomplete && filteredApps.isNotEmpty()) { - Popup( - popupPositionProvider = popupPositionProvider, - onDismissRequest = {}, - properties = PopupProperties(focusable = false), - ) { - Card( - modifier = Modifier.fillMaxWidth(0.9f), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - ), - shape = MaterialTheme.shapes.medium, - ) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { - filteredApps.take(5).forEach { app -> - DropdownMenuItem( - text = { Text(app.label) }, - onClick = { - val currentText = messageText.text - val selectionStart = messageText.selection.start - val textBeforeCursor = - currentText.take( - selectionStart, - ) - val textAfterCursor = - currentText.drop( - selectionStart, - ) - val mentionIndex = textBeforeCursor.lastIndexOf('@') - if (mentionIndex >= 0) { - val textBeforeMention = - textBeforeCursor.substring( - 0, - mentionIndex, - ) - val newText = - "$textBeforeMention@${app.label} $textAfterCursor" - val newCursorPosition = - mentionIndex + app.label.length + 2 - messageText = - TextFieldValue( - text = newText, - selection = - TextRange( - newCursorPosition, - ), - ) - selectedAppPackageName = app.packageName - } - }, - ) - } - } - } - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ModelDropdown( - modifier: Modifier = Modifier, - currentThread: ThreadEntity?, - onModelSelected: (LlmModel) -> Unit, - onMenuClick: () -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - modifier = modifier, - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - ) { - Surface( - modifier = Modifier.padding(bottom = 8.dp), - shadowElevation = 2.dp, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceBright, - ) { - val text = - currentThread?.llmModel?.modelName - ?: stringResource(R.string.agent_demo_select_model_to_create_thread) - val textColor = - if (currentThread != null) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.error - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .height(56.dp) - .padding(start = 4.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onMenuClick) { - Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu") - } - Row( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .menuAnchor( - ExposedDropdownMenuAnchorType.PrimaryEditable, - enabled = true, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.agent_demo_title), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null) - } - } - } - - LazyExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.exposedDropdownSize(), - containerColor = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(28.dp), - ) { - item { - Text( - "--- Gemini ---", - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - ) - } - val models = - listOf( - LlmModel.GEMINI_3_5_FLASH, - LlmModel.GEMINI_3_1_FLASH_LITE, - LlmModel.GEMINI_3_1_PRO_PREVIEW, - LlmModel.GEMINI_3_FLASH_PREVIEW, - LlmModel.GEMINI_3_1_FLASH_LITE_PREVIEW, - ) - items(models) { model -> - DropdownMenuItem( - text = { Text(model.modelName) }, - onClick = { - onModelSelected(model) - expanded = false - }, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - ) - } - } - } -} - @Composable fun MessageBubble( message: MessageEntity, isValidAction: Boolean, installedApps: List, + showAppFunctionDebugDetails: Boolean = true, + enableTextSelection: Boolean = true, onConfirmAction: (String) -> Unit, ) { val alignment = if (message.role == MessageRole.USER) Alignment.End else Alignment.Start @@ -649,178 +131,144 @@ fun MessageBubble( else -> MaterialTheme.colorScheme.onSurface } + val parsedData = + remember(message.textContent) { + parseMessageContent(message.textContent) + } + val cleanContentText = parsedData.first + val parsedCalls = parsedData.second + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 2.dp), + .padding(vertical = 4.dp), horizontalAlignment = alignment, ) { - Surface( - shape = MaterialTheme.shapes.large, - color = backgroundColor, - shadowElevation = if (message.role == MessageRole.ASSISTANT) 1.dp else 0.dp, - ) { - Column(modifier = Modifier.padding(12.dp)) { - SelectionContainer { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isError) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.debugging_error), - tint = textColor, - ) - Spacer(modifier = Modifier.width(8.dp)) - } - val contentText = - if ( - message.textContent.isEmpty() && - message.pendingIntentId != null - ) { - stringResource(R.string.agent_demo_action_confirmation_needed) - } else { - message.textContent + if (cleanContentText.isNotEmpty() || message.attachments.isNotEmpty()) { + Surface( + color = backgroundColor, + shape = + RoundedCornerShape( + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = if (message.role == MessageRole.USER) 20.dp else 4.dp, + bottomEnd = if (message.role == MessageRole.USER) 4.dp else 20.dp, + ), + shadowElevation = 1.dp, + modifier = + Modifier.fillMaxWidth( + if (message.role == MessageRole.USER) 0.85f else 0.95f, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (message.attachments.isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 8.dp), + ) { + message.attachments.forEach { _ -> + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding( + horizontal = 12.dp, + vertical = 8.dp, + ), + ) { + Text( + text = "Attachment", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } + } + } - if (message.role != MessageRole.USER) { - if (contentText.isNotEmpty()) { - Markdown(content = contentText) + if (cleanContentText.isNotEmpty()) { + val typographyStyle = + if (message.role == MessageRole.USER) { + MaterialTheme.typography.bodyLarge + } else { + MaterialTheme.typography.bodyMedium } - } else { - val chipBgColor = MaterialTheme.colorScheme.primary - val chipTextColor = MaterialTheme.colorScheme.onPrimary - val formattedText = - remember(contentText, installedApps) { - formatMessageText(contentText, installedApps) - } - val textMeasurer = rememberTextMeasurer() - val typographyStyle = MaterialTheme.typography.bodyLarge - val density = LocalDensity.current - - val inlineContentMap = - remember( - contentText, - installedApps, - chipBgColor, - chipTextColor, - density, - ) { - val map = mutableMapOf() - if (installedApps.isNotEmpty() && contentText.contains("@")) { - val appLabelsPattern = - installedApps.joinToString( - "|", - ) { Regex.escape(it.label) } - val regex = - Regex( - "@($appLabelsPattern)\\b", - RegexOption.IGNORE_CASE, - ) - regex.findAll(contentText).forEachIndexed { index, match -> - val id = "chip_$index" - val appName = match.value - val measured = - textMeasurer.measure( - text = appName, - style = - typographyStyle.copy( - fontWeight = FontWeight.Bold, - ), - ) - val widthSp = - with( - density, - ) { (measured.size.width + 8.dp.roundToPx()).toSp() } - val heightSp = - with( - density, - ) { (measured.size.height + 2.dp.roundToPx()).toSp() } - map[id] = - InlineTextContent( - Placeholder( - width = widthSp, - height = heightSp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ), - ) { - Surface( - shape = - androidx.compose.foundation.shape.RoundedCornerShape( - 6.dp, - ), - color = chipBgColor, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = appName, - color = chipTextColor, - style = - typographyStyle.copy( - fontWeight = FontWeight.Bold, - ), - modifier = - Modifier.padding( - horizontal = 4.dp, - vertical = 1.dp, - ), - ) - } - } - } - } - } - map + if (enableTextSelection) { + val annotatedText = + remember(cleanContentText, installedApps) { + formatMessageText(cleanContentText, installedApps) } + SelectionContainer { + Text( + text = annotatedText, + color = textColor, + style = typographyStyle, + ) + } + } else { Text( - text = formattedText, - inlineContent = inlineContentMap, + text = cleanContentText, color = textColor, style = typographyStyle, ) } } } + } + } - if (message.pendingIntentId != null) { - Spacer(modifier = Modifier.padding(vertical = 8.dp)) - androidx.compose.material3.Button( - onClick = { onConfirmAction(message.pendingIntentId) }, - enabled = isValidAction, - shape = CircleShape, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Text( + if (showAppFunctionDebugDetails && parsedCalls.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + parsedCalls.forEach { call -> + AppFunctionCallHintCard(call, installedApps) + } + } + + if (message.pendingIntentId != null) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(16.dp), + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth(0.95f), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = if (isValidAction) { - stringResource(R.string.agent_demo_confirm_action) + stringResource(R.string.agent_demo_action_confirmation_needed) } else { stringResource(R.string.agent_demo_action_expired) }, - ) - } - } - } - } - - if (message.role != MessageRole.USER) { - message.attachments.forEach { attachment -> - if (attachment.mimeType.startsWith("image/", ignoreCase = true)) { - Spacer(modifier = Modifier.height(6.dp)) - AsyncImage( - model = attachment.uri, - contentDescription = "Generated Image", - modifier = - Modifier - .fillMaxWidth(0.85f) - .height(240.dp) - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Crop, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), ) + if (isValidAction) { + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = { onConfirmAction(message.pendingIntentId) }, + ) { + Text(stringResource(R.string.agent_demo_confirm_action)) + } + } } } } @@ -855,13 +303,13 @@ fun StatusIndicator( try { val appInfo = packageManager.getApplicationInfo(status.packageName, 0) packageManager.getApplicationLabel(appInfo).toString() - } catch (e: Exception) { + } catch (_: Exception) { status.packageName } val appIcon = try { packageManager.getApplicationIcon(status.packageName) - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -917,135 +365,197 @@ fun ChatHistorySidePanel( Column( modifier = modifier - .width(280.dp) .fillMaxHeight() + .width(280.dp) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(16.dp), ) { - Text( - text = stringResource(R.string.agent_demo_chat_history), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 16.dp), - ) - - LazyColumn(modifier = Modifier.fillMaxSize()) { - items( - items = threads, - key = { thread -> thread.threadId }, - ) { thread -> - val isSelected = thread.threadId == currentThread?.threadId - val backgroundColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } - val textColor = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { - onEvent(AgentUiEvent.OnThreadSelected(thread.threadId)) - }, - shape = MaterialTheme.shapes.medium, - color = backgroundColor, - contentColor = textColor, + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.agent_demo_chat_history), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Surface( + onClick = { onEvent(AgentUiEvent.OnCreateThread(LlmModel.GEMINI_3_1_PRO_PREVIEW)) }, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = thread.llmModel.modelName, - style = MaterialTheme.typography.bodyMedium, - color = textColor, - ) - Text( - text = "ID: ${thread.threadId.take(8)}", - style = MaterialTheme.typography.bodySmall, - color = textColor.copy(alpha = 0.7f), - ) - } + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "New", + style = MaterialTheme.typography.labelMedium, + ) } } } - } -} -class InlineAppScopingVisualTransformation( - private val installedApps: List, - private val chipTextColor: Color, -) : VisualTransformation { - private val regex: Regex? = - if (installedApps.isNotEmpty()) { - val appLabelsPattern = installedApps.joinToString("|") { Regex.escape(it.label) } - Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE) - } else { - null - } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - override fun filter(text: AnnotatedString): TransformedText { - val rawText = text.text - val currentRegex = regex - if (currentRegex == null || !rawText.contains("@")) { - return TransformedText(text, OffsetMapping.Identity) - } - - val matches = currentRegex.findAll(rawText) - - val annotatedString = - buildAnnotatedString { - var lastIndex = 0 - matches.forEach { match -> - append(rawText.substring(lastIndex, match.range.first)) - pushStringAnnotation(tag = "mention", annotation = match.value) - withStyle( - SpanStyle( - color = chipTextColor, - fontWeight = FontWeight.Bold, - ), + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + items(threads) { thread -> + val isSelected = thread.threadId == currentThread?.threadId + Surface( + onClick = { onEvent(AgentUiEvent.OnThreadSelected(thread.threadId)) }, + shape = RoundedCornerShape(12.dp), + color = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - append(match.value) + Column(modifier = Modifier.weight(1f)) { + Text( + text = thread.llmModel.modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = "ID: ${thread.threadId.take(8)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } } - pop() - lastIndex = match.range.last + 1 - } - if (lastIndex < rawText.length) { - append(rawText.substring(lastIndex)) } } - return TransformedText(annotatedString, OffsetMapping.Identity) + } } } fun formatMessageText( text: String, installedApps: List, -): AnnotatedString { - if (installedApps.isEmpty() || !text.contains("@")) { - return AnnotatedString(text) - } - val appLabelsPattern = installedApps.joinToString("|") { Regex.escape(it.label) } - val regex = Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE) - val matches = regex.findAll(text) +): AnnotatedString = + buildAnnotatedString { + if (installedApps.isEmpty()) { + append(text) + return@buildAnnotatedString + } + + val appLabelsPattern = + installedApps.joinToString("|") { Regex.escape(it.label) } + val regex = Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE) - return buildAnnotatedString { var lastIndex = 0 - matches.forEachIndexed { index, match -> - val precedingText = text.substring(lastIndex, match.range.first) - if (precedingText.isNotEmpty()) { - append(precedingText) + regex.findAll(text).forEach { matchResult -> + append(text.substring(lastIndex, matchResult.range.first)) + + val appName = matchResult.groupValues[1] + val matchedApp = + installedApps.find { it.label.equals(appName, ignoreCase = true) } + + if (matchedApp != null) { + appendInlineContent(matchedApp.label, "@${matchedApp.label}") + } else { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(matchResult.value) + } } - appendInlineContent(id = "chip_$index", alternateText = match.value) - lastIndex = match.range.last + 1 + + lastIndex = matchResult.range.last + 1 } + if (lastIndex < text.length) { append(text.substring(lastIndex)) } } + +data class ParsedAppFunctionCall( + val packageName: String, + val functionId: String, + val arguments: Map, + val response: String?, +) + +fun parseMessageContent(text: String): Pair> { + val callRegex = Regex("@@AppFunctionCall:(.*?)@@") + val matches = callRegex.findAll(text) + val calls = mutableListOf() + + var cleanText = text + matches.forEach { match -> + cleanText = cleanText.replace(match.value, "") + } + + return Pair(cleanText.trim(), calls) +} + +@Composable +fun AppFunctionCallHintCard( + call: ParsedAppFunctionCall, + installedApps: List, + modifier: Modifier = Modifier, +) { + val appInfo = + remember(call.packageName, installedApps) { + installedApps.find { it.packageName == call.packageName } + } + + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + appInfo?.icon?.let { + Image( + bitmap = it.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${appInfo?.label ?: call.packageName} → ${call.functionId}", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } + } } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModel.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModel.kt index 2f92660..976fa99 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModel.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModel.kt @@ -109,16 +109,19 @@ class AgentDemoViewModel agentOrchestrator.status, savedStateHandle.getStateFlow(MainActivity.ARG_THREAD_ID, null), installedApps, - ) { - threads, - provider, - status, - targetThreadId, - apps, - -> - ThreadConfig(threads, provider, status, targetThreadId, apps) + settingsRepository.appFunctionDebuggingEnabled, + ) { flows -> + @Suppress("UNCHECKED_CAST") + ThreadConfig( + threads = flows[0] as List, + provider = flows[1] as LlmProviderName, + status = flows[2] as AgentStatus, + targetThreadId = flows[3] as String?, + installedApps = flows[4] as List, + isAppFunctionDebuggingEnabled = flows[5] as Boolean, + ) } - .collectLatest { (threads, provider, status, targetThreadId, apps) -> + .collectLatest { (threads, provider, status, targetThreadId, apps, isDebugEnabled) -> val currentThread = threads.find { it.threadId == targetThreadId } ?: threads.firstOrNull() @@ -140,6 +143,7 @@ class AgentDemoViewModel activePendingActionIds = currentLoadedState?.activePendingActionIds ?: emptySet(), installedApps = apps, + isAppFunctionDebuggingEnabled = isDebugEnabled, ) // Start observing messages for the current thread if not already doing so @@ -207,6 +211,11 @@ class AgentDemoViewModel launchPendingIntentUseCase(pendingIntent) } } + is AgentUiEvent.OnToggleAppFunctionDebugging -> { + viewModelScope.launch { + settingsRepository.setAppFunctionDebuggingEnabled(event.enabled) + } + } } } @@ -222,4 +231,5 @@ private data class ThreadConfig( val status: AgentStatus, val targetThreadId: String?, val installedApps: List, + val isAppFunctionDebuggingEnabled: Boolean, ) diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentUiState.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentUiState.kt index 4798f79..ffb79a1 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentUiState.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentUiState.kt @@ -32,6 +32,7 @@ sealed class AgentUiState { val threads: List = emptyList(), val activePendingActionIds: Set = emptySet(), val installedApps: List = emptyList(), + val isAppFunctionDebuggingEnabled: Boolean = true, ) : AgentUiState() } @@ -49,4 +50,6 @@ sealed class AgentUiEvent { data class OnThreadSelected(val threadId: String) : AgentUiEvent() data class OnConfirmAction(val pendingIntentId: String) : AgentUiEvent() + + data class OnToggleAppFunctionDebugging(val enabled: Boolean) : AgentUiEvent() } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/ConnectedAppsScreen.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/ConnectedAppsScreen.kt index 8de3114..724b0de 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/ConnectedAppsScreen.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/ConnectedAppsScreen.kt @@ -15,40 +15,16 @@ */ package com.example.appfunctions.agent.ui.screens.agentdemo -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.rememberAsyncImagePainter -import com.example.appfunctions.agent.R import com.example.appfunctions.agent.domain.appfunction.ConnectedAppInfo +import com.example.appfunctions.agent.ui.contracts.ConnectedAppsScreenLayout +import com.example.appfunctions.agent.ui.layout.rememberFormFactor +import com.example.appfunctions.agent.ui.layout.resolveConnectedAppsLayout /** Stateful composable for the Connected Apps screen. */ @Composable @@ -73,74 +49,14 @@ fun ConnectedAppsScreenContent( onBack: () -> Unit, onToggleApp: (String, Boolean) -> Unit, ) { - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Unspecified, - topBar = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.clickable { onBack() }, - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource(id = R.string.connected_apps_title), - style = MaterialTheme.typography.headlineMedium, - ) - } - }, - ) { paddingValues -> - Column( - modifier = - Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 24.dp), - ) { - LazyColumn { - items( - items = uiState.connectedApps, - key = { app -> app.packageName }, - ) { app -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - if (app.icon != null) { - Image( - painter = rememberAsyncImagePainter(app.icon), - contentDescription = null, - modifier = Modifier.size(40.dp), - ) - } else { - Box(modifier = Modifier.size(40.dp).background(Color.Gray)) - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = app.label, - style = MaterialTheme.typography.bodyLarge, - ) - if (!app.description.isNullOrEmpty()) { - Text( - text = app.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Switch( - checked = app.isConnected, - onCheckedChange = { connected -> - onToggleApp(app.packageName, connected) - }, - ) - } - } - } - } - } + val layout: ConnectedAppsScreenLayout = rememberFormFactor().resolveConnectedAppsLayout() + + layout.Content( + uiState = uiState, + onBack = onBack, + onToggleApp = onToggleApp, + modifier = Modifier, + ) } @Preview(showBackground = true) diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/SettingsScreen.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/SettingsScreen.kt index b10ad05..9ab2b8e 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/SettingsScreen.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/SettingsScreen.kt @@ -17,37 +17,17 @@ package com.example.appfunctions.agent.ui.screens.agentdemo import android.content.Intent import androidx.annotation.StringRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -55,26 +35,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.appfunctions.agent.BuildConfig import com.example.appfunctions.agent.R import com.example.appfunctions.agent.data.ServiceTier +import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout +import com.example.appfunctions.agent.ui.layout.rememberFormFactor +import com.example.appfunctions.agent.ui.layout.resolveSettingsLayout import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity -import kotlin.OptIn -/** Stateful composable for the Settings screen. */ @Composable fun SettingsScreen( onNavigateToConnectedApps: () -> Unit, @@ -99,7 +72,6 @@ fun SettingsScreen( } /** Stateless composable for the Settings screen, allowing for previews and easier testing. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreenContent( geminiApiKeyState: TextFieldState, @@ -108,159 +80,16 @@ fun SettingsScreenContent( onOpenLicenses: () -> Unit, onNavigateToConnectedApps: () -> Unit, ) { - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Unspecified, - topBar = { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.nav_settings), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.semantics { heading() }, - ) - } - }, - ) { paddingValues -> - Column( - modifier = - Modifier.fillMaxSize() - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .imePadding() - .verticalScroll(rememberScrollState()), - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(id = R.string.settings_agent), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 8.dp).semantics { heading() }, - ) - } - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(id = R.string.settings_gemini_api_key), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - - // NOTE: We use the value/onValueChange overload here because the new state - // overload might not support simple PasswordVisualTransformation as - // easily in this version. - if (BuildConfig.IS_RETAIL) { - OutlinedTextField( - value = geminiApiKeyState.text.toString(), - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - readOnly = true, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - shape = CircleShape, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = - MaterialTheme.colorScheme.surfaceBright, - focusedContainerColor = - MaterialTheme.colorScheme.surfaceBright, - ), - ) - } else { - OutlinedTextField( - value = geminiApiKeyState.text.toString(), - onValueChange = { geminiApiKeyState.setTextAndPlaceCursorAtEnd(it) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - shape = CircleShape, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = - MaterialTheme.colorScheme.surfaceBright, - focusedContainerColor = - MaterialTheme.colorScheme.surfaceBright, - ), - ) - } - } - - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(id = R.string.settings_service_tier), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - ServiceTierDropdown( - selectedTier = serviceTier, - onTierSelected = onServiceTierSelected, - ) - Text( - text = stringResource(id = R.string.settings_service_tier_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - - ListItem( - headlineContent = { - Text( - text = stringResource(id = R.string.manage_connected_apps), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon(imageVector = Icons.Default.Apps, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - ) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = - Modifier.clickable(role = Role.Button) { onNavigateToConnectedApps() }, - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) - - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(id = R.string.settings_about), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 8.dp).semantics { heading() }, - ) - } + val layout: SettingsScreenLayout = rememberFormFactor().resolveSettingsLayout() - ListItem( - headlineContent = { - Text( - text = stringResource(id = R.string.settings_open_source_licenses), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon(imageVector = Icons.Default.Info, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - ) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(role = Role.Button) { onOpenLicenses() }, - ) - } - } + layout.Content( + geminiApiKeyState = geminiApiKeyState, + serviceTier = serviceTier, + onServiceTierSelected = onServiceTierSelected, + onOpenLicenses = onOpenLicenses, + onNavigateToConnectedApps = onNavigateToConnectedApps, + modifier = Modifier, + ) } @Preview(showBackground = true) @@ -275,14 +104,14 @@ fun SettingsScreenPreview() { ) } -/** Dropdown allowing the user to pick the Gemini [ServiceTier] for agent requests. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ServiceTierDropdown( +fun ServiceTierDropdown( selectedTier: ServiceTier, onTierSelected: (ServiceTier) -> Unit, ) { var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it }, diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt index dadf8f8..7c426f6 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -57,12 +58,16 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -94,12 +99,25 @@ fun AppFunctionItem( }, ) + var isCardFocused by remember { mutableStateOf(false) } + val cardScale by animateFloatAsState(if (isCardFocused) 1.02f else 1.0f, label = "cardScale") + Surface( - modifier = modifier.fillMaxWidth(), - tonalElevation = tonalElevation, + modifier = + modifier + .fillMaxWidth() + .scale(cardScale) + .onFocusChanged { isCardFocused = it.isFocused }, + tonalElevation = if (isCardFocused) 8.dp else tonalElevation, shadowElevation = shadowElevation, shape = MaterialTheme.shapes.large, - color = surfaceColor, + color = if (isCardFocused) MaterialTheme.colorScheme.surfaceBright else surfaceColor, + border = + if (isCardFocused) { + BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + }, ) { Column( modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 8.dp, bottom = 16.dp), diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingScreen.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingScreen.kt index 1e58c4c..d30d7fa 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingScreen.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingScreen.kt @@ -16,81 +16,47 @@ package com.example.appfunctions.agent.ui.screens.debugging import android.app.PendingIntent -import android.content.res.Resources +import android.widget.Toast import androidx.appfunctions.metadata.AppFunctionMetadata -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.appfunctions.agent.R import com.example.appfunctions.agent.domain.appfunction.AppInfo +import com.example.appfunctions.agent.ui.contracts.DebuggingScreenLayout +import com.example.appfunctions.agent.ui.layout.rememberFormFactor +import com.example.appfunctions.agent.ui.layout.resolveDebuggingLayout import com.example.appfunctions.agent.ui.theme.AppFunctionsAgentTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun DebuggingScreen(viewModel: DebuggingViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(uiState.toastMessage) { + uiState.toastMessage?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + viewModel.onToastShown() + } + } DebuggingScreenContent( uiState = uiState, - onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) }, - onAppSelected = { viewModel.onAppSelected(it) }, - onClearSelectedApp = { viewModel.onClearSelectedApp() }, - onFunctionInputsChange = { functionId, inputs -> - viewModel.onFunctionInputsChange(functionId, inputs) - }, - onInvoke = { viewModel.invokeFunction(it) }, - onClearResult = { viewModel.clearResult() }, - onFunctionExpandedChange = { functionId, expanded -> - viewModel.onFunctionExpandedChange(functionId, expanded) - }, - onLaunchPendingIntent = { viewModel.launchPendingIntent(it) }, - onTogglePin = { viewModel.onTogglePin(it) }, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onAppSelected = viewModel::onAppSelected, + onClearSelectedApp = viewModel::onClearSelectedApp, + onFunctionInputsChange = viewModel::onFunctionInputsChange, + onInvoke = viewModel::invokeFunction, + onClearResult = viewModel::clearResult, + onFunctionExpandedChange = viewModel::onFunctionExpandedChange, + onLaunchPendingIntent = viewModel::launchPendingIntent, + onTogglePin = viewModel::onTogglePin, ) } @@ -108,233 +74,20 @@ fun DebuggingScreenContent( onLaunchPendingIntent: (PendingIntent) -> Unit, onTogglePin: (AppInfo) -> Unit, ) { - val focusManager = LocalFocusManager.current - LaunchedEffect(Unit) { focusManager.clearFocus() } - - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Unspecified, - topBar = { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Searchable Dropdown - AppDropdown( - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - appGroups = uiState.filteredApps, - searchQuery = uiState.searchQuery, - onSearchQueryChanged = onSearchQueryChanged, - onAppSelected = onAppSelected, - onClearSelectedApp = onClearSelectedApp, - onTogglePin = onTogglePin, - ) - } - }, - ) { paddingValues -> - Box( - modifier = - Modifier.fillMaxSize().padding(top = paddingValues.calculateTopPadding()), - ) { - when (val searchAppResultState = uiState.searchAppResultState) { - is SearchAppResultState.Idle -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.debugging_select_app_prompt), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - } - } - is SearchAppResultState.FunctionsFoundState -> { - FunctionsFoundContent( - state = searchAppResultState, - onFunctionExpandedChange = onFunctionExpandedChange, - onFunctionInputsChange = onFunctionInputsChange, - onInvoke = onInvoke, - onClearResult = onClearResult, - onLaunchPendingIntent = onLaunchPendingIntent, - ) - } - is SearchAppResultState.TroubleshootUiState -> { - TroubleshootResult( - state = searchAppResultState, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } -} + val layout: DebuggingScreenLayout = rememberFormFactor().resolveDebuggingLayout() -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppDropdown( - appGroups: AppsGroupState, - searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onAppSelected: (AppInfo) -> Unit, - onClearSelectedApp: () -> Unit, - onTogglePin: (AppInfo) -> Unit, - modifier: Modifier = Modifier, -) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - modifier = modifier, - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - ) { - Surface( - modifier = Modifier.padding(bottom = 8.dp), - shadowElevation = 2.dp, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceBright, - ) { - OutlinedTextField( - value = searchQuery, - shape = CircleShape, - singleLine = true, - placeholder = { Text(text = stringResource(R.string.debugging_search_app)) }, - onValueChange = { - onSearchQueryChanged(it) - expanded = true - }, - trailingIcon = { - Row( - modifier = Modifier.padding(end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onClearSelectedApp() }) { - Icon(Icons.Filled.Clear, contentDescription = "Clear") - } - } - } - }, - colors = - ExposedDropdownMenuDefaults.outlinedTextFieldColors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - modifier = - Modifier.fillMaxWidth() - .menuAnchor( - ExposedDropdownMenuAnchorType.Companion.PrimaryEditable, - enabled = true, - ), - ) - } - - val sections = appGroups.sections - val pinnedPackageNames = - remember(sections) { - sections - .find { it.titleRes == Resources.ID_NULL } - ?.apps - ?.map { it.packageName } - ?.toSet() ?: emptySet() - } - - LazyExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.exposedDropdownSize(), - containerColor = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(28.dp), - ) { - sections.forEachIndexed { index, section -> - if (index > 0) { - item { - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp), - ) - } - } - - // Not showing pinned section title - if (index != 0) { - item { - Text( - text = stringResource(id = section.titleRes), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - } - - items( - items = section.apps, - key = { app -> "${section.titleRes}_${app.packageName}" }, - ) { app -> - AppDropdownItem( - app = app, - isPinned = pinnedPackageNames.contains(app.packageName), - onAppSelected = onAppSelected, - onSearchQueryChanged = onSearchQueryChanged, - onTogglePin = onTogglePin, - onExpandedChange = { expanded = it }, - showPin = section.showPin, - ) - } - } - } - } -} - -@Composable -private fun AppDropdownItem( - app: AppInfo, - isPinned: Boolean, - onAppSelected: (AppInfo) -> Unit, - onSearchQueryChanged: (String) -> Unit, - onTogglePin: (AppInfo) -> Unit, - onExpandedChange: (Boolean) -> Unit, - showPin: Boolean = true, -) { - DropdownMenuItem( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - app.icon?.let { - Image( - bitmap = it.toBitmap().asImageBitmap(), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text(text = app.label, modifier = Modifier.weight(1f)) - if (showPin) { - IconButton(onClick = { onTogglePin(app) }) { - Icon( - imageVector = - if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, - contentDescription = if (isPinned) "Unpin" else "Pin", - tint = - if (isPinned) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } else { - Box(Modifier.minimumInteractiveComponentSize()) - } - } - }, - onClick = { - onSearchQueryChanged(app.label) - onAppSelected(app) - onExpandedChange(false) - }, + layout.Content( + uiState = uiState, + onSearchQueryChanged = onSearchQueryChanged, + onAppSelected = onAppSelected, + onClearSelectedApp = onClearSelectedApp, + onFunctionInputsChange = onFunctionInputsChange, + onInvoke = onInvoke, + onClearResult = onClearResult, + onFunctionExpandedChange = onFunctionExpandedChange, + onLaunchPendingIntent = onLaunchPendingIntent, + onTogglePin = onTogglePin, + modifier = Modifier, ) } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt index 5eb99bf..06a3618 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt @@ -27,6 +27,7 @@ data class DebuggingUiState( val searchQuery: String = "", val isLoading: Boolean = false, val searchAppResultState: SearchAppResultState = SearchAppResultState.Idle, + val toastMessage: String? = null, ) data class AppsGroupState( diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt index 5679fd6..fcd923e 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt @@ -16,7 +16,6 @@ package com.example.appfunctions.agent.ui.screens.debugging import android.app.PendingIntent -import android.content.res.Resources import androidx.appfunctions.metadata.AppFunctionMetadata import androidx.appfunctions.metadata.AppFunctionPackageMetadata import androidx.lifecycle.ViewModel @@ -32,7 +31,6 @@ import com.example.appfunctions.agent.domain.appfunction.GetInstalledAppsUseCase import com.example.appfunctions.agent.domain.pendingintent.LaunchPendingIntentUseCase import com.example.appfunctions.agent.domain.troubleshoot.TroubleshootAppUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -58,7 +56,6 @@ class DebuggingViewModel private val troubleshootAppUseCase: TroubleshootAppUseCase, private val launchPendingIntentUseCase: LaunchPendingIntentUseCase, private val settingsRepository: SettingsRepository, - @ApplicationContext private val context: android.content.Context, ) : ViewModel() { private val _uiState = MutableStateFlow(DebuggingUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -69,8 +66,16 @@ class DebuggingViewModel emptyMap() init { - loadInstalledApps() - loadAppFunctions() + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + allInstalledApps = getInstalledAppsUseCase() + getAppFunctionsUseCase().collect { appFunctionsMap -> + allAppFunctions = appFunctionsMap + _uiState.update { state -> + state.copy(filteredApps = filterApps(state.searchQuery), isLoading = false) + } + } + } observePinnedApps() } @@ -83,27 +88,9 @@ class DebuggingViewModel } } - private fun loadAppFunctions() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - getAppFunctionsUseCase().collect { appFunctionsMap -> - allAppFunctions = appFunctionsMap - updateAppsGroupState() - } - } - } - - private fun loadInstalledApps() { - viewModelScope.launch { - val apps = getInstalledAppsUseCase() - allInstalledApps = apps - updateAppsGroupState() - } - } - private fun updateAppsGroupState() { _uiState.update { state -> - state.copy(filteredApps = filterApps(state.searchQuery), isLoading = false) + state.copy(filteredApps = filterApps(state.searchQuery)) } } @@ -111,8 +98,8 @@ class DebuggingViewModel fun onAppSelected(appInfo: AppInfo) { val functions = allAppFunctions.entries.find { it.key.packageName == appInfo.packageName }?.value - if (functions == null) { - runTroubleshooting(appInfo.packageName) + if (functions.isNullOrEmpty()) { + runTroubleshooting(appInfo) } else { _uiState.update { state -> state.copy( @@ -124,6 +111,10 @@ class DebuggingViewModel } } + fun onToastShown() { + _uiState.update { it.copy(toastMessage = null) } + } + fun onClearSelectedApp() { _uiState.update { it.copy( @@ -302,15 +293,16 @@ class DebuggingViewModel } } - private fun runTroubleshooting(packageName: String) { + private fun runTroubleshooting(appInfo: AppInfo) { viewModelScope.launch { _uiState.update { it.copy( searchAppResultState = SearchAppResultState.TroubleshootUiState(isLoading = true), + toastMessage = "${appInfo.label} does not have any AppFunctions", ) } - val report = troubleshootAppUseCase(packageName) + val report = troubleshootAppUseCase(appInfo.packageName) _uiState.update { it.copy( searchAppResultState = @@ -336,7 +328,7 @@ class DebuggingViewModel buildList { val filteredPinned = pinnedApps.filter { it.label.contains(query, ignoreCase = true) } if (filteredPinned.isNotEmpty()) { - add(AppSection(Resources.ID_NULL, filteredPinned, true)) + add(AppSection(R.string.debugging_pinned_apps, filteredPinned, true)) } val filteredSupported = diff --git a/agent/app/src/main/res/values/strings.xml b/agent/app/src/main/res/values/strings.xml index 8a74b21..b5c7367 100644 --- a/agent/app/src/main/res/values/strings.xml +++ b/agent/app/src/main/res/values/strings.xml @@ -31,8 +31,10 @@ Debugging Page - Supported - Unsupported + Installed Apps + Pinned Apps + Apps with AppFunctions + Apps without AppFunctions Search App Select an app to see its functions Functions for %1$s: @@ -102,8 +104,8 @@ New Thread Ask Agent Send - Thinking... - Connecting... + Thinking… + Connecting… Error: %1$s Select a model to create a thread Select a model from the dropdown above to start a conversation diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt index 179ffe6..c05b15b 100644 --- a/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt +++ b/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt @@ -54,6 +54,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @@ -383,13 +384,14 @@ class AgentOrchestratorTest { ) { coEvery { observePendingMessagesUseCase(threadId) } returns flow { - delay(10) + delay(10.milliseconds) emit(message) } coEvery { manageThreadsUseCase.getThread(threadId) } returns flowOf(thread) coEvery { settingsRepository.geminiApiKey } returns flowOf(apiKey) coEvery { settingsRepository.disconnectedApps } returns flowOf(disconnectedApps) coEvery { settingsRepository.serviceTier } returns flowOf(ServiceTier.STANDARD) + coEvery { settingsRepository.appFunctionDebuggingEnabled } returns flowOf(false) coEvery { llmProviderFactory.getProvider(LlmProviderName.GEMINI) } returns llmProvider } @@ -429,7 +431,7 @@ class AgentOrchestratorTest { sendMessageUseCase( threadId = threadId, role = MessageRole.ASSISTANT, - textContent = "Here is your image!", + textContent = match { it.startsWith("Here is your image!") }, processingStatus = MessageProcessingStatus.PROCESSED, pendingIntentId = null, targetPackageName = null, diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModelTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModelTest.kt index 27a4805..54c0e76 100644 --- a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModelTest.kt +++ b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoViewModelTest.kt @@ -95,6 +95,7 @@ class AgentDemoViewModelTest { every { manageThreadsUseCase.getThreads() } returns threadsFlow every { settingsRepository.selectedProvider } returns selectedProviderFlow + every { settingsRepository.appFunctionDebuggingEnabled } returns flowOf(true) every { agentOrchestrator.status } returns agentStatusFlow every { getChatHistoryUseCase(any()) } returns messagesFlow every { observeActivePendingIntentsUseCase() } returns activePendingActionIdsFlow diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/FakeSettingsRepository.kt b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/FakeSettingsRepository.kt index 7ea3b0b..216dfb8 100644 --- a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/FakeSettingsRepository.kt +++ b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/agentdemo/FakeSettingsRepository.kt @@ -70,4 +70,11 @@ class FakeSettingsRepository : SettingsRepository { _disconnectedApps.value += packageName } } + + private val _appFunctionDebuggingEnabled = MutableStateFlow(true) + override val appFunctionDebuggingEnabled: Flow = _appFunctionDebuggingEnabled + + override suspend fun setAppFunctionDebuggingEnabled(enabled: Boolean) { + _appFunctionDebuggingEnabled.value = enabled + } } diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt index fa4237c..7809475 100644 --- a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt +++ b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt @@ -111,7 +111,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -162,7 +161,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -208,7 +206,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -239,7 +236,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -270,7 +266,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -309,7 +304,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle() @@ -346,7 +340,6 @@ class DebuggingViewModelTest { mockTroubleshootAppUseCase, mockLaunchPendingIntentUseCase, mockSettingsRepository, - context, ) advanceUntilIdle()