diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 8e8384451..dd0363ac0 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -43,6 +43,7 @@ class App : Application() { process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return CrashHandler.install(this) com.droiddeck.launcher.lab.LabNotice.started(this) + com.droiddeck.launcher.agent.AgentHost.refresh(this) com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8deb14120..1ae3b59c2 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1148,6 +1148,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, onClose = { pcKeyboardOpen = false }, ) + if (!drawerOpen && !pcKeyboardOpen) com.droiddeck.launcher.ui.AgentBubble { + drawerPage = SessionDrawerPage.AGENT + drawerOpen = true + } if (!drawerDocked) SessionMenu(docked = false) LaunchedEffect(achievementsOpen) { if (achievementsOpen) { diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt index ae92033ab..28a0ecd14 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt @@ -42,6 +42,15 @@ object AgentHost { private set var installed by mutableStateOf(false) private set + /** A reply finished since the conversation was last on screen. */ + var unseen by mutableStateOf(false) + private set + var failed by mutableStateOf(false) + private set + + fun seen() { + unseen = false + } private val main = Handler(Looper.getMainLooper()) private var process: Process? = null @@ -150,6 +159,8 @@ object AgentHost { private fun finish(app: Context, error: String?) { record(app, Entry.Finished(error)) state = State.IDLE + unseen = true + failed = error != null AgentHostService.stop(app) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt index 8349c27fd..5dd9ac14d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt @@ -34,6 +34,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.animation.core.animateFloat +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.offset +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -45,9 +54,10 @@ import com.droiddeck.launcher.agent.AgentHost import com.droiddeck.launcher.agent.AgentHost.Entry @Composable -internal fun AgentPage(modifier: Modifier) { +internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier) { val context = LocalContext.current remember { AgentHost.refresh(context) } + LaunchedEffect(AgentHost.unseen) { AgentHost.seen() } if (AgentHost.state == AgentHost.State.MISSING) { Column(modifier) { Note(stringResource(R.string.agent_missing)) } return @@ -79,7 +89,7 @@ internal fun AgentPage(modifier: Modifier) { Icon(Icons.Outlined.Add, stringResource(R.string.agent_new)) } OutlinedTextField( - draft, { draft = it }, Modifier.weight(1f), + draft, { draft = it }, Modifier.weight(1f).then(inputModifier), placeholder = { Text(stringResource(R.string.agent_hint)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { send() }), @@ -111,3 +121,73 @@ private fun AgentEntry(entry: Entry) { } } } + +private object BubblePlace { + var x by mutableStateOf(Float.NaN) + var y by mutableStateOf(Float.NaN) + var hidden by mutableStateOf(false) +} + +/** + * The agent's bubble over a session: there once the agent has been used, pulsing while it works, + * marked when a reply or failure has not been seen. Drag it to either edge; a long press hides it + * until the next session. + */ +@Composable +internal fun AgentBubble(onOpen: () -> Unit) { + if (!AgentHost.installed || BubblePlace.hidden || AgentHost.entries.isEmpty()) return + val colors = MaterialTheme.colorScheme + val density = androidx.compose.ui.platform.LocalDensity.current + val size = 56.dp + val sizePx = with(density) { size.toPx() } + val margin = with(density) { 12.dp.toPx() } + val working = AgentHost.state == AgentHost.State.WORKING + val pulse = androidx.compose.animation.core.rememberInfiniteTransition(label = "agentPulse") + val ring by pulse.animateFloat( + 0.35f, 1f, + androidx.compose.animation.core.infiniteRepeatable(androidx.compose.animation.core.tween(900), androidx.compose.animation.core.RepeatMode.Reverse), + label = "agentRing", + ) + androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { + val width = constraints.maxWidth.toFloat() + val height = constraints.maxHeight.toFloat() + if (BubblePlace.x.isNaN()) { + BubblePlace.x = width - sizePx - margin + BubblePlace.y = height * 0.3f + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { androidx.compose.ui.unit.IntOffset(BubblePlace.x.toInt(), BubblePlace.y.toInt()) } + .size(size) + .graphicsLayer { alpha = if (working || AgentHost.unseen) 1f else 0.7f } + .clip(androidx.compose.foundation.shape.CircleShape) + .background(colors.surface) + .border( + 2.dp, + if (working) colors.primary.copy(alpha = ring) else if (AgentHost.failed && AgentHost.unseen) colors.error else colors.outlineVariant, + androidx.compose.foundation.shape.CircleShape, + ) + .pointerInput(width, height) { + detectDragGestures(onDragEnd = { + BubblePlace.x = if (BubblePlace.x + sizePx / 2 < width / 2) margin else width - sizePx - margin + }) { change, drag -> + change.consume() + BubblePlace.x = (BubblePlace.x + drag.x).coerceIn(0f, width - sizePx) + BubblePlace.y = (BubblePlace.y + drag.y).coerceIn(0f, height - sizePx) + } + } + .pointerInput(Unit) { + detectTapGestures(onTap = { onOpen() }, onLongPress = { BubblePlace.hidden = true }) + } + .testTag("agent-bubble"), + ) { + Icon(androidx.compose.material.icons.Icons.Outlined.AutoAwesome, stringResource(R.string.rail_agent), tint = colors.primary) + if (AgentHost.unseen && !working) Box( + Modifier.align(Alignment.TopEnd).padding(6.dp).size(10.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(if (AgentHost.failed) colors.error else colors.primary), + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index 07289bdf2..a0d75f027 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -9,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) { EFFECTS("look"), VR("steamvr"), GAMES("anisotropy"), + AGENT("agent-input"), SESSION("suspend"), } -internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List = +internal fun sessionDrawerPages( + hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed, +): List = SessionDrawerPage.entries.filter { - (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) + (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) && + (hasAgent || it != SessionDrawerPage.AGENT) } internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 7315817c1..fcdd22987 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.ScreenShare import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PictureInPictureAlt @@ -172,6 +173,7 @@ private val SessionDrawerPage.title: Int get() = when (this) { SessionDrawerPage.VR -> R.string.vr_title SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects SessionDrawerPage.GAMES -> R.string.game_settings_title + SessionDrawerPage.AGENT -> R.string.rail_agent SessionDrawerPage.SESSION -> R.string.mode_session } @@ -184,6 +186,7 @@ private val SessionDrawerPage.icon get() = when (this) { SessionDrawerPage.VR -> VrHeadsetIcon SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh SessionDrawerPage.GAMES -> Icons.Outlined.Layers + SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome SessionDrawerPage.SESSION -> Icons.Outlined.Settings } @@ -762,6 +765,10 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont track = { key -> focus.track(page, key) }, ) } + SessionDrawerPage.AGENT -> AgentPage( + Modifier.fillMaxWidth().height(460.dp).padding(horizontal = 14.dp), + inputModifier = focus.track(page, "agent-input"), + ) SessionDrawerPage.SESSION -> { if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) { SettingsRow(stringResource(R.string.drawer_launch_app), null) { diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt index 35561a41d..15888d289 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt @@ -26,4 +26,11 @@ class SessionDrawerPageTest { assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1)) } } + + @Test fun theAgentPageComesBeforeSessionOnlyWhenTheAgentIsInstalled() { + val with = sessionDrawerPages(false, hasAgent = true) + assertEquals(SessionDrawerPage.AGENT, with.step(SessionDrawerPage.GAMES, 1)) + assertEquals(SessionDrawerPage.SESSION, with.step(SessionDrawerPage.AGENT, 1)) + assertFalse(SessionDrawerPage.AGENT in sessionDrawerPages(false, hasAgent = false)) + } }