diff --git a/agent/app/src/main/AndroidManifest.xml b/agent/app/src/main/AndroidManifest.xml
index 7fa52ed..642e51a 100644
--- a/agent/app/src/main/AndroidManifest.xml
+++ b/agent/app/src/main/AndroidManifest.xml
@@ -25,9 +25,12 @@
+
+
+
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/MainActivity.kt b/agent/app/src/main/java/com/example/appfunctions/agent/MainActivity.kt
index 6b4a6a8..5dfe32f 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/MainActivity.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/MainActivity.kt
@@ -21,6 +21,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -30,16 +33,16 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Scaffold
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -50,6 +53,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
+import com.example.appfunctions.agent.ui.layout.AdaptiveMainNavigation
import com.example.appfunctions.agent.ui.screens.agentdemo.AgentDemoScreen
import com.example.appfunctions.agent.ui.screens.agentdemo.ConnectedAppsScreen
import com.example.appfunctions.agent.ui.screens.agentdemo.SettingsScreen
@@ -155,38 +159,16 @@ fun MainScreen(
}
}
- Scaffold(
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- bottomBar = {
- NavigationBar {
- val navBackStackEntry by navController.currentBackStackEntryAsState()
- val currentDestination = navBackStackEntry?.destination
- items.forEachIndexed { index, screen ->
- NavigationBarItem(
- icon = { Icon(icons[index], contentDescription = labels[index]) },
- label = { Text(labels[index]) },
- selected =
- currentDestination?.hierarchy?.any {
- it.route?.startsWith(screen) == true
- } == true,
- onClick = {
- navController.navigate(screen) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- },
- )
- }
- }
- },
- ) { innerPadding ->
+ AdaptiveMainNavigation(
+ navController = navController,
+ items = items,
+ icons = icons,
+ labels = labels,
+ ) { contentModifier ->
NavHost(
navController = navController,
startDestination = "debugging",
- modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding),
+ modifier = contentModifier,
) {
composable("debugging") { DebuggingScreen() }
composable(
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/troubleshoot/CheckMainlineVersionUseCase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/troubleshoot/CheckMainlineVersionUseCase.kt
index abad414..cc9337c 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/domain/troubleshoot/CheckMainlineVersionUseCase.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/troubleshoot/CheckMainlineVersionUseCase.kt
@@ -32,17 +32,21 @@ class CheckMainlineVersionUseCase
*/
operator fun invoke(): Boolean {
val pm = context.packageManager
- return try {
- val packageInfo =
- pm.getPackageInfo(
- APPSEARCH_MODULE_NAME,
- PackageManager.PackageInfoFlags.of(PackageManager.MATCH_APEX.toLong()),
- )
- val versionCode = packageInfo.longVersionCode
- versionCode > REQUIRED_VERSION
- } catch (e: PackageManager.NameNotFoundException) {
- false
+ for (moduleName in listOf(APPSEARCH_MODULE_NAME, "com.android.appsearch")) {
+ try {
+ val packageInfo =
+ pm.getPackageInfo(
+ moduleName,
+ PackageManager.PackageInfoFlags.of(PackageManager.MATCH_APEX.toLong()),
+ )
+ val versionCode = packageInfo.longVersionCode
+ if (versionCode > REQUIRED_VERSION) return true
+ } catch (e: PackageManager.NameNotFoundException) {
+ // Try next package or fallback
+ }
}
+ // Fallback to true if running on API Level >= 36
+ return android.os.Build.VERSION.SDK_INT >= 36
}
companion object {
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/components/TvSurfaceTextField.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/components/TvSurfaceTextField.kt
new file mode 100644
index 0000000..a27015f
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/components/TvSurfaceTextField.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.components
+
+import kotlin.OptIn
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.text.TextRange
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Shape
+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.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.unit.dp
+import com.example.appfunctions.agent.R
+
+/**
+ * Android TV Surface Paradigm Text Field.
+ *
+ * Unfocused / Idle state: Renders a focusable Surface container that responds to D-Pad navigation.
+ * High contrast focus stroke border and scale boost when focused.
+ * Pressing D-Pad Center (or click) enters editing mode and focuses the underlying OutlinedTextField.
+ * Pressing Back or Enter/Done exits editing mode, hides the keyboard, and restores D-Pad navigation.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun TvSurfaceTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ singleLine: Boolean = true,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ shape: Shape = CircleShape,
+) {
+ var isEditing by remember { mutableStateOf(false) }
+ var wasEditing by remember { mutableStateOf(false) }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val textFieldFocusRequester = remember { FocusRequester() }
+ val surfaceFocusRequester = remember { FocusRequester() }
+
+ val isImeVisible = WindowInsets.isImeVisible
+ var keyboardHasShown by remember(isEditing) { mutableStateOf(false) }
+
+ fun stopEditing() {
+ isEditing = false
+ keyboardController?.hide()
+ }
+
+ BackHandler(enabled = isEditing) {
+ stopEditing()
+ }
+
+ LaunchedEffect(isEditing) {
+ if (isEditing) {
+ wasEditing = true
+ textFieldFocusRequester.requestFocus()
+ } else if (wasEditing) {
+ surfaceFocusRequester.requestFocus()
+ }
+ }
+
+ LaunchedEffect(isImeVisible) {
+ if (isImeVisible) {
+ keyboardHasShown = true
+ } else if (keyboardHasShown && isEditing) {
+ stopEditing()
+ }
+ }
+
+ if (isEditing) {
+ var tfValue by remember {
+ mutableStateOf(
+ androidx.compose.ui.text.input.TextFieldValue(
+ text = value,
+ selection = androidx.compose.ui.text.TextRange(0, value.length),
+ )
+ )
+ }
+ LaunchedEffect(value) {
+ if (tfValue.text != value) {
+ tfValue = tfValue.copy(text = value, selection = TextRange(value.length))
+ }
+ }
+ var tfFocused by remember { mutableStateOf(false) }
+ OutlinedTextField(
+ value = tfValue,
+ onValueChange = { newTfValue ->
+ tfValue = newTfValue
+ onValueChange(newTfValue.text)
+ },
+ singleLine = singleLine,
+ label = label?.let { { Text(it) } },
+ placeholder = { Text(placeholder) },
+ trailingIcon = trailingIcon,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = KeyboardActions(
+ onDone = { stopEditing() },
+ onSearch = { stopEditing() },
+ ),
+ shape = shape,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
+ ),
+ modifier = modifier
+ .defaultMinSize(minHeight = 52.dp)
+ .focusRequester(textFieldFocusRequester)
+ .onFocusChanged { focusState ->
+ if (tfFocused && !focusState.isFocused) {
+ stopEditing()
+ }
+ tfFocused = focusState.isFocused
+ }
+ .onPreviewKeyEvent { keyEvent ->
+ if (keyEvent.key == Key.Back) {
+ if (keyEvent.type == KeyEventType.KeyDown || keyEvent.type == KeyEventType.KeyUp) {
+ stopEditing()
+ }
+ true
+ } else false
+ }
+ )
+ } else {
+ var isFocused by remember { mutableStateOf(false) }
+ val scale by animateFloatAsState(if (isFocused) 1.02f else 1.0f, label = "textFieldScale")
+
+ Surface(
+ onClick = { isEditing = true },
+ modifier = modifier
+ .defaultMinSize(minHeight = 52.dp)
+ .scale(scale)
+ .focusRequester(surfaceFocusRequester)
+ .onFocusChanged { isFocused = it.isFocused },
+ shape = shape,
+ color = if (isFocused) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceBright,
+ border = if (isFocused) {
+ BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary)
+ } else {
+ BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+ },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ if (label != null) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isFocused) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.primary,
+ )
+ }
+ Text(
+ text = value.ifEmpty { placeholder },
+ color = when {
+ isFocused -> MaterialTheme.colorScheme.onPrimaryContainer
+ value.isNotEmpty() -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ trailingIcon?.invoke()
+ }
+ }
+ }
+}
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..4798a75
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/contracts/SettingsScreenLayout.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.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * Mandatory interface contract for Settings screen layouts.
+ * Both Mobile and TV implementations MUST conform to this contract.
+ */
+interface SettingsScreenLayout {
+ @Composable
+ fun Content(
+ geminiApiKeyState: TextFieldState,
+ onOpenLicenses: () -> Unit,
+ onNavigateToConnectedApps: () -> Unit,
+ modifier: Modifier,
+ )
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveInputContainer.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveInputContainer.kt
new file mode 100644
index 0000000..d17ab71
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveInputContainer.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AdaptiveInputContainer(
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ val isTv = isTvFormFactor()
+
+ if (isTv) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ ) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ tonalElevation = 6.dp,
+ modifier = modifier
+ .widthIn(max = 560.dp)
+ .fillMaxWidth(),
+ ) {
+ content()
+ }
+ }
+ } else {
+ ModalBottomSheet(
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ onDismissRequest = onDismissRequest,
+ dragHandle = null,
+ ) {
+ content()
+ }
+ }
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveMainNavigation.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveMainNavigation.kt
new file mode 100644
index 0000000..245a9bd
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/layout/AdaptiveMainNavigation.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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 androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.currentBackStackEntryAsState
+
+@Composable
+fun AdaptiveMainNavigation(
+ navController: NavHostController,
+ items: List,
+ icons: List,
+ labels: List,
+ modifier: Modifier = Modifier,
+ content: @Composable (Modifier) -> Unit,
+) {
+ val isTv = isTvFormFactor()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry?.destination
+
+ if (isTv) {
+ Row(
+ modifier = modifier
+ .fillMaxSize()
+ .consumeWindowInsets(WindowInsets(0, 0, 0, 0)),
+ ) {
+ NavigationRail(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ items.forEachIndexed { index, screen ->
+ NavigationRailItem(
+ icon = { Icon(icons[index], contentDescription = labels[index]) },
+ label = { Text(labels[index]) },
+ selected =
+ currentDestination?.hierarchy?.any {
+ it.route?.startsWith(screen) == true
+ } == true,
+ onClick = {
+ navController.navigate(screen) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ content(
+ Modifier
+ .weight(1f)
+ .fillMaxSize(),
+ )
+ }
+ } else {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ bottomBar = {
+ NavigationBar {
+ items.forEachIndexed { index, screen ->
+ NavigationBarItem(
+ icon = { Icon(icons[index], contentDescription = labels[index]) },
+ label = { Text(labels[index]) },
+ selected =
+ currentDestination?.hierarchy?.any {
+ it.route?.startsWith(screen) == true
+ } == true,
+ onClick = {
+ navController.navigate(screen) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ )
+ }
+ }
+ },
+ ) { innerPadding ->
+ content(
+ Modifier
+ .padding(innerPadding)
+ .fillMaxSize(),
+ )
+ }
+ }
+}
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/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..9bbf870
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileAgentDemoContent.kt
@@ -0,0 +1,123 @@
+/*
+ * 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.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import com.example.appfunctions.agent.ui.contracts.AgentDemoScreenLayout
+import com.example.appfunctions.agent.ui.screens.agentdemo.AgentDemoLoadedScreen
+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 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 -> {
+ 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()
+ }
+ }
+}
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..b53b571
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileConnectedAppsContent.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+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,
+ )
+ }
+}
+
+@Composable
+fun MobileConnectedAppsContent(
+ uiState: ConnectedAppsUiState,
+ onBack: () -> Unit,
+ onToggleApp: (String, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ 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)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
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..8df1232
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/agentdemo/MobileSettingsContent.kt
@@ -0,0 +1,202 @@
+/*
+ * 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.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.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.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.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.unit.dp
+import com.example.appfunctions.agent.BuildConfig
+import com.example.appfunctions.agent.R
+import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout
+
+object MobileSettingsLayout : SettingsScreenLayout {
+ @Composable
+ override fun Content(
+ geminiApiKeyState: TextFieldState,
+ onOpenLicenses: () -> Unit,
+ onNavigateToConnectedApps: () -> Unit,
+ modifier: Modifier,
+ ) {
+ MobileSettingsContent(
+ geminiApiKeyState = geminiApiKeyState,
+ onOpenLicenses = onOpenLicenses,
+ onNavigateToConnectedApps = onNavigateToConnectedApps,
+ modifier = modifier,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MobileSettingsContent(
+ geminiApiKeyState: TextFieldState,
+ onOpenLicenses: () -> Unit,
+ onNavigateToConnectedApps: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ 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),
+ )
+
+ 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(),
+ )
+ }
+ }
+
+ 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..3099469
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/mobile/debugging/MobileDebuggingContent.kt
@@ -0,0 +1,288 @@
+/*
+ * 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.appfunctions.metadata.AppFunctionMetadata
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+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.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+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.AppDropdownItem
+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,
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ containerColor = Color.Unspecified,
+ topBar = {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ MobileAppDropdown(
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
+ appGroups = uiState.filteredApps,
+ selectedApp = uiState.selectedApp,
+ 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(),
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MobileAppDropdown(
+ appGroups: AppsGroupState,
+ selectedApp: AppInfo?,
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ onAppSelected: (AppInfo) -> Unit,
+ onClearSelectedApp: () -> Unit,
+ onTogglePin: (AppInfo) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+
+ val sections = appGroups.sections
+ val pinnedPackageNames =
+ remember(sections) {
+ sections
+ .find { it.titleRes == Resources.ID_NULL }
+ ?.apps
+ ?.map { it.packageName }
+ ?.toSet() ?: emptySet()
+ }
+
+ OutlinedCard(
+ onClick = { expanded = !expanded },
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = selectedApp?.label ?: stringResource(R.string.debugging_select_app_prompt),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ },
+ supportingContent = if (selectedApp?.packageName != null) {
+ { Text(text = selectedApp.packageName) }
+ } else null,
+ leadingContent = if (selectedApp?.icon != null) {
+ {
+ Image(
+ bitmap = selectedApp.icon.toBitmap().asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ } else null,
+ trailingContent = {
+ if (selectedApp != null) {
+ IconButton(onClick = { onClearSelectedApp() }) {
+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
+ }
+ } else {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ )
+ }
+
+ if (expanded) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ ) {
+ Column {
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = onSearchQueryChanged,
+ placeholder = { Text(stringResource(R.string.debugging_search_app)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ singleLine = true,
+ )
+
+ LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) {
+ sections.forEach { section ->
+ item {
+ if (section.titleRes != Resources.ID_NULL) {
+ Text(
+ text = stringResource(section.titleRes),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ }
+ items(section.apps) { app ->
+ AppDropdownItem(
+ app = app,
+ isPinned = pinnedPackageNames.contains(app.packageName),
+ onAppSelected = {
+ onAppSelected(it)
+ expanded = false
+ focusManager.clearFocus()
+ },
+ onSearchQueryChanged = onSearchQueryChanged,
+ onTogglePin = { onTogglePin(app) },
+ onExpandedChange = { expanded = it },
+ showPin = section.showPin,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
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 327c8cf..326c615 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
@@ -116,7 +116,7 @@ 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 com.example.appfunctions.agent.R
import com.example.appfunctions.agent.data.LlmModel
@@ -126,7 +126,12 @@ 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.contracts.AgentDemoScreenLayout
+import com.example.appfunctions.agent.ui.layout.FormFactor
+import com.example.appfunctions.agent.ui.layout.rememberFormFactor
+import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileAgentDemoLayout
import com.example.appfunctions.agent.ui.screens.debugging.LazyExposedDropdownMenu
+import com.example.appfunctions.agent.ui.tv.agentdemo.TvAgentDemoLayout
import com.mikepenz.markdown.m3.Markdown
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -138,72 +143,24 @@ 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 formFactor = rememberFormFactor()
+ val layout: AgentDemoScreenLayout = when (formFactor) {
+ FormFactor.TV -> TvAgentDemoLayout
+ FormFactor.WEAR, FormFactor.AUTO, FormFactor.XR, FormFactor.MOBILE -> MobileAgentDemoLayout
}
+
+ layout.Content(
+ uiState = uiState,
+ onEvent = onEvent,
+ initialSidePanelVisible = initialSidePanelVisible,
+ modifier = Modifier,
+ )
}
@Composable
@@ -224,6 +181,7 @@ fun AgentDemoLoadedScreen(
packageManager: PackageManager,
initialSidePanelVisible: Boolean = false,
) {
+ val focusManager = LocalFocusManager.current
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var isSidePanelVisible by remember { mutableStateOf(initialSidePanelVisible) }
var selectedAppPackageName by remember { mutableStateOf(null) }
@@ -400,43 +358,17 @@ fun AgentDemoLoadedScreen(
// Input area
Box(modifier = Modifier.fillMaxWidth()) {
- OutlinedTextField(
- value = messageText,
- onValueChange = { newValue ->
- messageText = newValue
- val currentText = newValue.text
+ com.example.appfunctions.agent.ui.components.TvSurfaceTextField(
+ value = messageText.text,
+ placeholder = stringResource(R.string.agent_demo_ask_agent),
+ onValueChange = { newString ->
+ messageText = TextFieldValue(newString)
if (selectedAppPackageName != null && appMentionRegex != null) {
- if (!appMentionRegex.containsMatchIn(currentText)) {
+ if (!appMentionRegex.containsMatchIn(newString)) {
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,
@@ -451,6 +383,7 @@ fun AgentDemoLoadedScreen(
)
}
},
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
)
if (showAutocomplete && filteredApps.isNotEmpty()) {
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..256f255 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
@@ -44,11 +44,16 @@ 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.FormFactor
+import com.example.appfunctions.agent.ui.layout.rememberFormFactor
+import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileConnectedAppsLayout
+import com.example.appfunctions.agent.ui.tv.agentdemo.TvConnectedAppsLayout
/** Stateful composable for the Connected Apps screen. */
@Composable
@@ -73,74 +78,18 @@ 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 formFactor = rememberFormFactor()
+ val layout: ConnectedAppsScreenLayout = when (formFactor) {
+ FormFactor.TV -> TvConnectedAppsLayout
+ FormFactor.WEAR, FormFactor.AUTO, FormFactor.XR, FormFactor.MOBILE -> MobileConnectedAppsLayout
}
+
+ 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 b455b65..51d90b8 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
@@ -59,13 +59,19 @@ 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 com.example.appfunctions.agent.BuildConfig
import com.example.appfunctions.agent.R
import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity
import kotlin.OptIn
/** Stateful composable for the Settings screen. */
+import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout
+import com.example.appfunctions.agent.ui.layout.FormFactor
+import com.example.appfunctions.agent.ui.layout.rememberFormFactor
+import com.example.appfunctions.agent.ui.mobile.agentdemo.MobileSettingsLayout
+import com.example.appfunctions.agent.ui.tv.agentdemo.TvSettingsLayout
+
@Composable
fun SettingsScreen(
onNavigateToConnectedApps: () -> Unit,
@@ -87,148 +93,24 @@ fun SettingsScreen(
}
/** Stateless composable for the Settings screen, allowing for previews and easier testing. */
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreenContent(
geminiApiKeyState: TextFieldState,
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,
- ),
- )
- }
- }
-
- 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() },
- )
- }
+ val formFactor = rememberFormFactor()
+ val layout: SettingsScreenLayout = when (formFactor) {
+ FormFactor.TV -> TvSettingsLayout
+ FormFactor.WEAR, FormFactor.AUTO, FormFactor.XR, FormFactor.MOBILE -> MobileSettingsLayout
}
+
+ layout.Content(
+ geminiApiKeyState = geminiApiKeyState,
+ onOpenLicenses = onOpenLicenses,
+ onNavigateToConnectedApps = onNavigateToConnectedApps,
+ modifier = Modifier,
+ )
}
@Preview(showBackground = true)
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionDataTypeInput.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionDataTypeInput.kt
index 8eff41a..4cd7255 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionDataTypeInput.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionDataTypeInput.kt
@@ -37,7 +37,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+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.platform.LocalFocusManager
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -49,12 +56,15 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
+import com.example.appfunctions.agent.ui.layout.AdaptiveInputContainer
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.ui.window.Dialog
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
@@ -180,8 +190,7 @@ fun AppFunctionDataTypeInput(
)
if (showSheet) {
- ModalBottomSheet(
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ AdaptiveInputContainer(
onDismissRequest = { showSheet = false },
) {
AppFunctionObjectTypeSheetContent(
@@ -227,8 +236,7 @@ fun AppFunctionDataTypeInput(
)
if (showSheet) {
- ModalBottomSheet(
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ AdaptiveInputContainer(
onDismissRequest = { showSheet = false },
) {
AppFunctionObjectTypeSheetContent(
@@ -275,30 +283,14 @@ fun PrimitiveTextInput(
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
- OutlinedTextField(
+ com.example.appfunctions.agent.ui.components.TvSurfaceTextField(
value = value,
onValueChange = onValueChange,
- label = {
- Row {
- Text(label)
- if (isRequired) {
- Text(
- text = stringResource(R.string.debugging_required_indicator),
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.labelSmall,
- )
- }
- }
- },
- modifier = modifier.height(72.dp).fillMaxWidth(),
- singleLine = true,
+ placeholder = label,
+ label = if (isRequired) "$label *" else label,
+ modifier = modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions,
shape = MaterialTheme.shapes.medium,
- colors =
- OutlinedTextFieldDefaults.colors(
- unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
- focusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
- ),
)
}
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..a332116 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,8 +31,10 @@ 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.ui.focus.onFocusChanged
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -57,7 +59,9 @@ 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
@@ -70,6 +74,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.appfunctions.agent.R
+import com.example.appfunctions.agent.ui.layout.isTvFormFactor
import com.example.appfunctions.agent.ui.theme.GoogleSansCodeFontFamily
@Composable
@@ -94,12 +99,22 @@ fun AppFunctionItem(
},
)
+ val isTv = isTvFormFactor()
+ var isCardFocused by remember { mutableStateOf(false) }
+
Surface(
- modifier = modifier.fillMaxWidth(),
+ modifier = modifier
+ .fillMaxWidth()
+ .onFocusChanged { isCardFocused = it.hasFocus },
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
shape = MaterialTheme.shapes.large,
color = surfaceColor,
+ border = if (isTv && isCardFocused) {
+ BorderStroke(2.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..2158ec0 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
@@ -18,20 +18,25 @@ package com.example.appfunctions.agent.ui.screens.debugging
import android.app.PendingIntent
import android.content.res.Resources
import androidx.appfunctions.metadata.AppFunctionMetadata
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
@@ -43,7 +48,10 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -57,19 +65,32 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+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.foundation.text.KeyboardActions
+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.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.FormFactor
+import com.example.appfunctions.agent.ui.mobile.debugging.MobileDebuggingLayout
import com.example.appfunctions.agent.ui.theme.AppFunctionsAgentTheme
+import com.example.appfunctions.agent.ui.tv.debugging.TvDebuggingLayout
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -108,188 +129,29 @@ 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 formFactor = rememberFormFactor()
+ val layout: DebuggingScreenLayout = when (formFactor) {
+ FormFactor.TV -> TvDebuggingLayout
+ FormFactor.WEAR, FormFactor.AUTO, FormFactor.XR, FormFactor.MOBILE -> MobileDebuggingLayout
}
-}
-@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,
- )
- }
- }
- }
- }
+ layout.Content(
+ uiState = uiState,
+ onSearchQueryChanged = onSearchQueryChanged,
+ onAppSelected = onAppSelected,
+ onClearSelectedApp = onClearSelectedApp,
+ onFunctionInputsChange = onFunctionInputsChange,
+ onInvoke = onInvoke,
+ onClearResult = onClearResult,
+ onFunctionExpandedChange = onFunctionExpandedChange,
+ onLaunchPendingIntent = onLaunchPendingIntent,
+ onTogglePin = onTogglePin,
+ modifier = Modifier,
+ )
}
@Composable
-private fun AppDropdownItem(
+internal fun AppDropdownItem(
app: AppInfo,
isPinned: Boolean,
onAppSelected: (AppInfo) -> Unit,
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvAgentDemoContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvAgentDemoContent.kt
new file mode 100644
index 0000000..6fc5609
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvAgentDemoContent.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.tv.agentdemo
+
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import com.example.appfunctions.agent.ui.contracts.AgentDemoScreenLayout
+import com.example.appfunctions.agent.ui.screens.agentdemo.AgentDemoLoadedScreen
+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
+
+object TvAgentDemoLayout : AgentDemoScreenLayout {
+ @Composable
+ override fun Content(
+ uiState: AgentUiState,
+ onEvent: (AgentUiEvent) -> Unit,
+ initialSidePanelVisible: Boolean,
+ modifier: Modifier,
+ ) {
+ TvAgentDemoContent(
+ uiState = uiState,
+ onEvent = onEvent,
+ initialSidePanelVisible = initialSidePanelVisible,
+ modifier = modifier,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TvAgentDemoContent(
+ uiState: AgentUiState,
+ onEvent: (AgentUiEvent) -> Unit,
+ initialSidePanelVisible: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val packageManager = context.packageManager
+ val focusManager = LocalFocusManager.current
+
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) { focusManager.clearFocus() }
+
+ when (uiState) {
+ is AgentUiState.Loading -> {
+ AgentDemoLoadingScreen()
+ }
+
+ is AgentUiState.Loaded -> {
+ AgentDemoLoadedScreen(
+ uiState = uiState,
+ onEvent = onEvent,
+ isWideScreen = true, // Force landscape dual-pane for TV D-Pad navigation
+ drawerState = drawerState,
+ scope = scope,
+ packageManager = packageManager,
+ initialSidePanelVisible = initialSidePanelVisible,
+ )
+ }
+ }
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvConnectedAppsContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvConnectedAppsContent.kt
new file mode 100644
index 0000000..588ffcb
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvConnectedAppsContent.kt
@@ -0,0 +1,164 @@
+/*
+ * 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.tv.agentdemo
+
+import androidx.compose.foundation.BorderStroke
+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.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.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+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.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+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 TvConnectedAppsLayout : ConnectedAppsScreenLayout {
+ @Composable
+ override fun Content(
+ uiState: ConnectedAppsUiState,
+ onBack: () -> Unit,
+ onToggleApp: (String, Boolean) -> Unit,
+ modifier: Modifier,
+ ) {
+ TvConnectedAppsContent(
+ uiState = uiState,
+ onBack = onBack,
+ onToggleApp = onToggleApp,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+fun TvConnectedAppsContent(
+ uiState: ConnectedAppsUiState,
+ onBack: () -> Unit,
+ onToggleApp: (String, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ containerColor = Color.Unspecified,
+ topBar = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ 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 ->
+ var isRowFocused by remember { mutableStateOf(false) }
+ Surface(
+ onClick = { onToggleApp(app.packageName, !app.isConnected) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .onFocusChanged { isRowFocused = it.isFocused },
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ border = if (isRowFocused) {
+ BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
+ } else null,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.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 = null,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvSettingsContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvSettingsContent.kt
new file mode 100644
index 0000000..f1a3e75
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/agentdemo/TvSettingsContent.kt
@@ -0,0 +1,222 @@
+/*
+ * 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.tv.agentdemo
+
+import androidx.compose.foundation.BorderStroke
+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.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.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.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+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.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.example.appfunctions.agent.BuildConfig
+import com.example.appfunctions.agent.R
+import com.example.appfunctions.agent.ui.components.TvSurfaceTextField
+import com.example.appfunctions.agent.ui.contracts.SettingsScreenLayout
+
+object TvSettingsLayout : SettingsScreenLayout {
+ @Composable
+ override fun Content(
+ geminiApiKeyState: TextFieldState,
+ onOpenLicenses: () -> Unit,
+ onNavigateToConnectedApps: () -> Unit,
+ modifier: Modifier,
+ ) {
+ TvSettingsContent(
+ geminiApiKeyState = geminiApiKeyState,
+ onOpenLicenses = onOpenLicenses,
+ onNavigateToConnectedApps = onNavigateToConnectedApps,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+fun TvSettingsContent(
+ geminiApiKeyState: TextFieldState,
+ onOpenLicenses: () -> Unit,
+ onNavigateToConnectedApps: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ containerColor = Color.Unspecified,
+ topBar = {
+ Row(
+ modifier = Modifier.padding(horizontal = 24.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 = 24.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 = 24.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) {
+ TvSurfaceTextField(
+ value = geminiApiKeyState.text.toString(),
+ onValueChange = {},
+ placeholder = stringResource(id = R.string.settings_gemini_api_key),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ TvSurfaceTextField(
+ value = geminiApiKeyState.text.toString(),
+ onValueChange = { geminiApiKeyState.setTextAndPlaceCursorAtEnd(it) },
+ placeholder = stringResource(id = R.string.settings_gemini_api_key),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+
+ var isAppsFocused by remember { mutableStateOf(false) }
+ Surface(
+ onClick = onNavigateToConnectedApps,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .onFocusChanged { isAppsFocused = it.isFocused },
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ border = if (isAppsFocused) {
+ BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
+ } else null,
+ ) {
+ 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),
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp),
+ color = MaterialTheme.colorScheme.outlineVariant,
+ )
+
+ Column(modifier = Modifier.padding(horizontal = 24.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() },
+ )
+ }
+
+ var isLicensesFocused by remember { mutableStateOf(false) }
+ Surface(
+ onClick = onOpenLicenses,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .onFocusChanged { isLicensesFocused = it.isFocused },
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ border = if (isLicensesFocused) {
+ BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
+ } else null,
+ ) {
+ 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),
+ )
+ }
+ }
+ }
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/debugging/TvDebuggingContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/debugging/TvDebuggingContent.kt
new file mode 100644
index 0000000..a61a0b2
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/tv/debugging/TvDebuggingContent.kt
@@ -0,0 +1,327 @@
+/*
+ * 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.tv.debugging
+
+import android.app.PendingIntent
+import android.content.res.Resources
+import androidx.appfunctions.metadata.AppFunctionMetadata
+import androidx.compose.foundation.BorderStroke
+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.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+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.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.appfunctions.agent.R
+import com.example.appfunctions.agent.domain.appfunction.AppInfo
+import com.example.appfunctions.agent.ui.components.TvSurfaceTextField
+import com.example.appfunctions.agent.ui.contracts.DebuggingScreenLayout
+import com.example.appfunctions.agent.ui.screens.debugging.AppDropdownItem
+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
+import kotlinx.coroutines.android.awaitFrame
+
+object TvDebuggingLayout : 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,
+ ) {
+ TvDebuggingContent(
+ uiState = uiState,
+ onSearchQueryChanged = onSearchQueryChanged,
+ onAppSelected = onAppSelected,
+ onClearSelectedApp = onClearSelectedApp,
+ onFunctionInputsChange = onFunctionInputsChange,
+ onInvoke = onInvoke,
+ onClearResult = onClearResult,
+ onFunctionExpandedChange = onFunctionExpandedChange,
+ onLaunchPendingIntent = onLaunchPendingIntent,
+ onTogglePin = onTogglePin,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+fun TvDebuggingContent(
+ 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,
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ containerColor = Color.Unspecified,
+ topBar = {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TvAppDropdown(
+ modifier = Modifier.weight(1f),
+ 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(),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TvAppDropdown(
+ appGroups: AppsGroupState,
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ onAppSelected: (AppInfo) -> Unit,
+ onClearSelectedApp: () -> Unit,
+ onTogglePin: (AppInfo) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+
+ val sections = appGroups.sections
+ val pinnedPackageNames =
+ remember(sections) {
+ sections
+ .find { it.titleRes == Resources.ID_NULL }
+ ?.apps
+ ?.map { it.packageName }
+ ?.toSet() ?: emptySet()
+ }
+
+ val dropdownFocusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ awaitFrame()
+ dropdownFocusRequester.requestFocus()
+ }
+
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TvSurfaceTextField(
+ value = searchQuery,
+ placeholder = stringResource(R.string.debugging_search_app),
+ onValueChange = onSearchQueryChanged,
+ trailingIcon = if (searchQuery.isNotEmpty()) {
+ {
+ IconButton(onClick = { onClearSelectedApp() }) {
+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
+ }
+ }
+ } else null,
+ modifier = Modifier.weight(1f),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Surface(
+ onClick = { expanded = !expanded },
+ modifier = Modifier
+ .focusRequester(dropdownFocusRequester)
+ .onPreviewKeyEvent { keyEvent ->
+ if (expanded && keyEvent.key == Key.Back && keyEvent.type == KeyEventType.KeyUp) {
+ expanded = false
+ true
+ } else false
+ },
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceBright,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Apps",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Toggle Dropdown",
+ )
+ }
+ }
+ }
+
+ if (expanded) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .onPreviewKeyEvent { keyEvent ->
+ if (keyEvent.key == Key.Back && keyEvent.type == KeyEventType.KeyUp) {
+ expanded = false
+ true
+ } else false
+ },
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ ) {
+ LazyColumn(modifier = Modifier.padding(8.dp)) {
+ sections.forEachIndexed { index, section ->
+ if (index > 0) {
+ item {
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
+ )
+ }
+ }
+
+ 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 = 6.dp),
+ )
+ }
+ }
+
+ items(
+ items = section.apps,
+ key = { app -> "${section.titleRes}_${app.packageName}" },
+ ) { app ->
+ AppDropdownItem(
+ app = app,
+ isPinned = pinnedPackageNames.contains(app.packageName),
+ onAppSelected = {
+ onAppSelected(it)
+ expanded = false
+ focusManager.clearFocus()
+ },
+ onSearchQueryChanged = onSearchQueryChanged,
+ onTogglePin = onTogglePin,
+ onExpandedChange = { expanded = it },
+ showPin = section.showPin,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/agent/app/src/main/res/drawable-xhdpi/tv_banner.png b/agent/app/src/main/res/drawable-xhdpi/tv_banner.png
new file mode 100644
index 0000000..c074574
Binary files /dev/null and b/agent/app/src/main/res/drawable-xhdpi/tv_banner.png differ
diff --git a/agent/app/src/main/res/drawable/ic_launcher_background.xml b/agent/app/src/main/res/drawable/ic_launcher_background.xml
index 6a1535a..909c853 100644
--- a/agent/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/agent/app/src/main/res/drawable/ic_launcher_background.xml
@@ -21,166 +21,11 @@
android:viewportWidth="108"
android:viewportHeight="108">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:strokeColor="#3300E5FF" />
diff --git a/agent/app/src/main/res/drawable/ic_launcher_foreground.xml b/agent/app/src/main/res/drawable/ic_launcher_foreground.xml
index 2aaf647..46770fb 100644
--- a/agent/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/agent/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,3 +1,4 @@
+
-
-
-
-
-
-
-
-
+
+ android:fillColor="#00E5FF"
+ android:pathData="M54,24 A30,30 0 1,0 54,84 A30,30 0 1,0 54,24 Z"
+ android:fillAlpha="0.15" />
+
+
+
+
+
+
\ No newline at end of file
diff --git a/agent/app/src/main/res/drawable/tv_banner.png b/agent/app/src/main/res/drawable/tv_banner.png
new file mode 100644
index 0000000..c074574
Binary files /dev/null and b/agent/app/src/main/res/drawable/tv_banner.png differ
diff --git a/agent/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/agent/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78..0000000
Binary files a/agent/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/agent/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d..0000000
Binary files a/agent/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/agent/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d6..0000000
Binary files a/agent/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/agent/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611d..0000000
Binary files a/agent/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/agent/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a307..0000000
Binary files a/agent/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/agent/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a695..0000000
Binary files a/agent/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77..0000000
Binary files a/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f50..0000000
Binary files a/agent/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d642..0000000
Binary files a/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae3..0000000
Binary files a/agent/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ