mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
feat(agent): the agent in the session drawer and as a bubble over the game
This commit is contained in:
1 parent
71481ac2ff
commit
2d15205ff7
7 files changed
+118
-4
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SessionDrawerPage> =
|
||||
internal fun sessionDrawerPages(
|
||||
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
|
||||
): List<SessionDrawerPage> =
|
||||
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<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user