feat: add realtime mobile sync dashboard
This commit is contained in:
@@ -210,6 +210,8 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
|
|||||||
|
|
||||||
安卓端当前包含:
|
安卓端当前包含:
|
||||||
|
|
||||||
|
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
|
||||||
|
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
|
||||||
- 会话创建、切换、持续对话
|
- 会话创建、切换、持续对话
|
||||||
- 任务分组查看、暂停、恢复、取消、重排
|
- 任务分组查看、暂停、恢复、取消、重排
|
||||||
- 审批查看与批准/拒绝
|
- 审批查看与批准/拒绝
|
||||||
@@ -217,6 +219,12 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- 绑定新设备并生成启动命令
|
- 绑定新设备并生成启动命令
|
||||||
- 云端 Boss 地址切换与重排入口
|
- 云端 Boss 地址切换与重排入口
|
||||||
|
|
||||||
|
同步模型说明:
|
||||||
|
|
||||||
|
- 电脑端 Web 控制台已经使用 SSE 实时事件流
|
||||||
|
- 安卓端现在也使用 SSE,并在断线时自动重连和降级轮询
|
||||||
|
- 手机和电脑都直接读写同一套 Boss control plane 状态,所以项目、消息、审批和任务进度会尽量保持高同步
|
||||||
|
|
||||||
一键本地 demo:
|
一键本地 demo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package site.hyzq.bossandroid.network
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.BufferedSource
|
||||||
|
import site.hyzq.bossandroid.model.BossEvent
|
||||||
|
|
||||||
|
enum class SyncConnectionState {
|
||||||
|
CONNECTING,
|
||||||
|
LIVE,
|
||||||
|
RECONNECTING,
|
||||||
|
}
|
||||||
|
|
||||||
|
class BossRealtimeSync(
|
||||||
|
private val client: OkHttpClient = OkHttpClient(),
|
||||||
|
private val json: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
suspend fun run(
|
||||||
|
baseUrl: String,
|
||||||
|
onStatusChanged: (SyncConnectionState) -> Unit,
|
||||||
|
onEvent: (BossEvent) -> Unit,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
var firstAttempt = true
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
onStatusChanged(if (firstAttempt) SyncConnectionState.CONNECTING else SyncConnectionState.RECONNECTING)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${normalizeBaseUrl(baseUrl)}/api/events/stream")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("SSE connection failed: ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val source = response.body?.source() ?: throw IOException("Empty event stream response")
|
||||||
|
onStatusChanged(SyncConnectionState.LIVE)
|
||||||
|
readStream(source, onEvent)
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
onStatusChanged(SyncConnectionState.RECONNECTING)
|
||||||
|
delay(RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstAttempt = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readStream(
|
||||||
|
source: BufferedSource,
|
||||||
|
onEvent: (BossEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val payloadLines = mutableListOf<String>()
|
||||||
|
|
||||||
|
while (!source.exhausted()) {
|
||||||
|
val rawLine = source.readUtf8Line() ?: break
|
||||||
|
when {
|
||||||
|
rawLine.startsWith(":") -> Unit
|
||||||
|
rawLine.isBlank() -> {
|
||||||
|
if (payloadLines.isNotEmpty()) {
|
||||||
|
val event = json.decodeFromString<BossEvent>(payloadLines.joinToString("\n"))
|
||||||
|
onEvent(event)
|
||||||
|
payloadLines.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLine.startsWith("data:") -> {
|
||||||
|
payloadLines += rawLine.removePrefix("data:").trimStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeBaseUrl(input: String): String {
|
||||||
|
val trimmed = input.trim().trimEnd('/')
|
||||||
|
return if (trimmed.isBlank()) {
|
||||||
|
BossApi.DEFAULT_BASE_URL
|
||||||
|
} else if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||||
|
trimmed
|
||||||
|
} else {
|
||||||
|
"http://$trimmed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val RETRY_DELAY_MS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package site.hyzq.bossandroid.ui
|
package site.hyzq.bossandroid.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -76,6 +77,7 @@ import site.hyzq.bossandroid.model.Message
|
|||||||
import site.hyzq.bossandroid.model.Session
|
import site.hyzq.bossandroid.model.Session
|
||||||
import site.hyzq.bossandroid.model.TaskItem
|
import site.hyzq.bossandroid.model.TaskItem
|
||||||
import site.hyzq.bossandroid.model.WorkerNode
|
import site.hyzq.bossandroid.model.WorkerNode
|
||||||
|
import site.hyzq.bossandroid.network.SyncConnectionState
|
||||||
|
|
||||||
private val TaskGroups = listOf(
|
private val TaskGroups = listOf(
|
||||||
"进行中" to listOf("assigned", "running"),
|
"进行中" to listOf("assigned", "running"),
|
||||||
@@ -111,7 +113,7 @@ fun BossApp(
|
|||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Boss 主控")
|
Text("Boss 主控")
|
||||||
Text(
|
Text(
|
||||||
text = topBarSubtitle(uiState, selectedSession, selectedWorker),
|
text = "${syncStatusShortLabel(uiState.syncConnectionState)} · ${topBarSubtitle(uiState, selectedSession, selectedWorker)}",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -162,6 +164,7 @@ fun BossApp(
|
|||||||
selectedSession = selectedSession,
|
selectedSession = selectedSession,
|
||||||
selectedWorker = selectedWorker,
|
selectedWorker = selectedWorker,
|
||||||
onSelectSession = viewModel::selectSession,
|
onSelectSession = viewModel::selectSession,
|
||||||
|
onSelectWorker = viewModel::selectWorker,
|
||||||
onSelectTab = viewModel::selectConversationTab,
|
onSelectTab = viewModel::selectConversationTab,
|
||||||
onCreateSession = viewModel::createSession,
|
onCreateSession = viewModel::createSession,
|
||||||
onSendMessage = viewModel::sendMessage,
|
onSendMessage = viewModel::sendMessage,
|
||||||
@@ -202,6 +205,7 @@ private fun ConversationsScreen(
|
|||||||
selectedSession: Session?,
|
selectedSession: Session?,
|
||||||
selectedWorker: WorkerNode?,
|
selectedWorker: WorkerNode?,
|
||||||
onSelectSession: (String) -> Unit,
|
onSelectSession: (String) -> Unit,
|
||||||
|
onSelectWorker: (String?) -> Unit,
|
||||||
onSelectTab: (ConversationTab) -> Unit,
|
onSelectTab: (ConversationTab) -> Unit,
|
||||||
onCreateSession: (String) -> Unit,
|
onCreateSession: (String) -> Unit,
|
||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
@@ -233,6 +237,7 @@ private fun ConversationsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
CreateSessionCard(onCreateSession = onCreateSession)
|
CreateSessionCard(onCreateSession = onCreateSession)
|
||||||
|
GlobalSyncCard(uiState = uiState)
|
||||||
SessionSelector(
|
SessionSelector(
|
||||||
sessions = uiState.sessions,
|
sessions = uiState.sessions,
|
||||||
selectedSessionId = uiState.selectedSessionId,
|
selectedSessionId = uiState.selectedSessionId,
|
||||||
@@ -248,20 +253,16 @@ private fun ConversationsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSession == null) {
|
if (uiState.conversationTab != ConversationTab.OVERVIEW) {
|
||||||
EmptyStateCard(
|
selectedSession?.let { session ->
|
||||||
title = "还没有可用会话",
|
|
||||||
body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。",
|
|
||||||
)
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionSummaryCard(
|
SessionSummaryCard(
|
||||||
session = selectedSession,
|
session = session,
|
||||||
workerCount = uiState.workers.size,
|
workerCount = uiState.workers.size,
|
||||||
onArchive = onArchiveSession,
|
onArchive = onArchiveSession,
|
||||||
onRestore = onRestoreSession,
|
onRestore = onRestoreSession,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TabRow(selectedTabIndex = uiState.conversationTab.ordinal) {
|
TabRow(selectedTabIndex = uiState.conversationTab.ordinal) {
|
||||||
ConversationTab.entries.forEach { tab ->
|
ConversationTab.entries.forEach { tab ->
|
||||||
@@ -271,6 +272,7 @@ private fun ConversationsScreen(
|
|||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = when (tab) {
|
text = when (tab) {
|
||||||
|
ConversationTab.OVERVIEW -> "总览"
|
||||||
ConversationTab.CHAT -> "对话"
|
ConversationTab.CHAT -> "对话"
|
||||||
ConversationTab.TASKS -> "任务"
|
ConversationTab.TASKS -> "任务"
|
||||||
ConversationTab.APPROVALS -> "审批"
|
ConversationTab.APPROVALS -> "审批"
|
||||||
@@ -282,13 +284,32 @@ private fun ConversationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (uiState.conversationTab) {
|
when (uiState.conversationTab) {
|
||||||
ConversationTab.CHAT -> ChatTab(
|
ConversationTab.OVERVIEW -> OverviewTab(
|
||||||
|
uiState = uiState,
|
||||||
|
onSelectSession = onSelectSession,
|
||||||
|
onSelectWorker = onSelectWorker,
|
||||||
|
)
|
||||||
|
|
||||||
|
ConversationTab.CHAT -> if (selectedSession == null) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "先选择一个项目",
|
||||||
|
body = "总览里能看到所有客户端的对话;如果要继续某个项目,请先选中它。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatTab(
|
||||||
sessionMessages = sessionMessages,
|
sessionMessages = sessionMessages,
|
||||||
sessionEvents = sessionEvents,
|
sessionEvents = sessionEvents,
|
||||||
onSendMessage = onSendMessage,
|
onSendMessage = onSendMessage,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ConversationTab.TASKS -> TasksTab(
|
ConversationTab.TASKS -> if (selectedSession == null) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "先选择一个项目",
|
||||||
|
body = "选中项目后,这里会展示它的任务树和实时进度。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TasksTab(
|
||||||
tasks = filteredTasks,
|
tasks = filteredTasks,
|
||||||
workers = uiState.workers,
|
workers = uiState.workers,
|
||||||
onPauseTask = onPauseTask,
|
onPauseTask = onPauseTask,
|
||||||
@@ -296,8 +317,15 @@ private fun ConversationsScreen(
|
|||||||
onCancelTask = onCancelTask,
|
onCancelTask = onCancelTask,
|
||||||
onRequeueTask = onRequeueTask,
|
onRequeueTask = onRequeueTask,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ConversationTab.APPROVALS -> ApprovalsTab(
|
ConversationTab.APPROVALS -> if (selectedSession == null) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "先选择一个项目",
|
||||||
|
body = "选中项目后,这里会展示对应的审批项。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ApprovalsTab(
|
||||||
approvals = sessionApprovals,
|
approvals = sessionApprovals,
|
||||||
tasks = sessionTasks,
|
tasks = sessionTasks,
|
||||||
onApprove = onApprove,
|
onApprove = onApprove,
|
||||||
@@ -306,6 +334,7 @@ private fun ConversationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DevicesScreen(
|
private fun DevicesScreen(
|
||||||
@@ -320,6 +349,9 @@ private fun DevicesScreen(
|
|||||||
val relatedTasks = remember(uiState.tasks) {
|
val relatedTasks = remember(uiState.tasks) {
|
||||||
uiState.tasks.associateBy { it.id }
|
uiState.tasks.associateBy { it.id }
|
||||||
}
|
}
|
||||||
|
val relatedSessions = remember(uiState.sessions) {
|
||||||
|
uiState.sessions.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -354,9 +386,11 @@ private fun DevicesScreen(
|
|||||||
SectionHeading("已绑定设备")
|
SectionHeading("已绑定设备")
|
||||||
uiState.workers.forEach { worker ->
|
uiState.workers.forEach { worker ->
|
||||||
val task = worker.currentTaskId?.let { relatedTasks[it] }
|
val task = worker.currentTaskId?.let { relatedTasks[it] }
|
||||||
|
val session = task?.let { relatedSessions[it.sessionId] }
|
||||||
WorkerCard(
|
WorkerCard(
|
||||||
worker = worker,
|
worker = worker,
|
||||||
currentTask = task,
|
currentTask = task,
|
||||||
|
currentSession = session,
|
||||||
selected = worker.id == uiState.selectedWorkerId,
|
selected = worker.id == uiState.selectedWorkerId,
|
||||||
onSelect = { onSelectWorker(worker.id) },
|
onSelect = { onSelectWorker(worker.id) },
|
||||||
onMarkOffline = { onMarkOffline(worker.id) },
|
onMarkOffline = { onMarkOffline(worker.id) },
|
||||||
@@ -419,10 +453,11 @@ private fun SettingsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
MetricRow("连接状态", if (uiState.isRefreshing) "同步中" else "在线")
|
MetricRow("同步模式", syncStatusShortLabel(uiState.syncConnectionState))
|
||||||
MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString())
|
MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString())
|
||||||
MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString())
|
MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString())
|
||||||
MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步")
|
MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步")
|
||||||
|
MetricRow("最近事件", uiState.lastEventType ?: "暂无")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = onReconcile) {
|
Button(onClick = onReconcile) {
|
||||||
Icon(Icons.Outlined.SyncAlt, contentDescription = null)
|
Icon(Icons.Outlined.SyncAlt, contentDescription = null)
|
||||||
@@ -440,7 +475,7 @@ private fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
"这个安卓版本已经覆盖了主控对话、设备绑定、设备切换、任务查看、审批处理和主控地址切换。下一步可以继续补推送通知、SSE 实时流和扫码绑定。",
|
"这个安卓版本现在会优先走 SSE 实时同步,自动刷新所有客户端的项目、对话和任务进度;当事件流断开时,会自动降级轮询保证数据继续同步。",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -448,6 +483,164 @@ private fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GlobalSyncCard(
|
||||||
|
uiState: BossUiState,
|
||||||
|
) {
|
||||||
|
val activeTaskCount = uiState.tasks.count { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("全局同步", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
syncStatusLongLabel(uiState.syncConnectionState),
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StatusChip(
|
||||||
|
label = syncStatusShortLabel(uiState.syncConnectionState),
|
||||||
|
tone = syncTone(uiState.syncConnectionState),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
AssistChip(onClick = {}, label = { Text("${uiState.sessions.size} 个项目") })
|
||||||
|
AssistChip(onClick = {}, label = { Text("${uiState.workers.size} 台客户端") })
|
||||||
|
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"最近事件:${uiState.lastEventType ?: "等待新事件"}",
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"最后同步:${uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.9f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverviewTab(
|
||||||
|
uiState: BossUiState,
|
||||||
|
onSelectSession: (String) -> Unit,
|
||||||
|
onSelectWorker: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val messagesBySession = remember(uiState.messages) {
|
||||||
|
uiState.messages.groupBy { it.sessionId }
|
||||||
|
}
|
||||||
|
val tasksBySession = remember(uiState.tasks) {
|
||||||
|
uiState.tasks.groupBy { it.sessionId }
|
||||||
|
}
|
||||||
|
val workerById = remember(uiState.workers) {
|
||||||
|
uiState.workers.associateBy { it.id }
|
||||||
|
}
|
||||||
|
val activeTasks = remember(uiState.tasks) {
|
||||||
|
uiState.tasks.filter { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
|
||||||
|
}
|
||||||
|
val recentMessages = remember(uiState.messages) {
|
||||||
|
uiState.messages.sortedByDescending { it.createdAt }.take(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
SectionHeading("客户端实时视图")
|
||||||
|
if (uiState.workers.isEmpty()) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "还没有在线客户端",
|
||||||
|
body = "绑定电脑端 worker 后,这里会自动显示每台设备当前正在处理的项目和任务。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState.workers.forEach { worker ->
|
||||||
|
val task = worker.currentTaskId?.let { taskId -> uiState.tasks.firstOrNull { it.id == taskId } }
|
||||||
|
val session = task?.let { uiState.sessions.firstOrNull { sessionItem -> sessionItem.id == it.sessionId } }
|
||||||
|
ClientActivityCard(
|
||||||
|
worker = worker,
|
||||||
|
task = task,
|
||||||
|
session = session,
|
||||||
|
onSelectWorker = { onSelectWorker(worker.id) },
|
||||||
|
onSelectSession = { session?.id?.let(onSelectSession) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeading("所有项目")
|
||||||
|
if (uiState.sessions.isEmpty()) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "还没有项目",
|
||||||
|
body = "你可以在手机上创建,也可以在电脑入口创建。Boss 会自动把它们同步到这里。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState.sessions.forEach { session ->
|
||||||
|
val recentMessage = messagesBySession[session.id]?.maxByOrNull { it.createdAt }
|
||||||
|
val sessionTasks = tasksBySession[session.id].orEmpty()
|
||||||
|
val sessionWorkerNames = sessionTasks
|
||||||
|
.mapNotNull { task -> task.assignedWorkerId?.let(workerById::get)?.name }
|
||||||
|
.distinct()
|
||||||
|
ProjectOverviewCard(
|
||||||
|
session = session,
|
||||||
|
recentMessage = recentMessage,
|
||||||
|
activeTaskCount = sessionTasks.count {
|
||||||
|
it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval")
|
||||||
|
},
|
||||||
|
workerNames = sessionWorkerNames,
|
||||||
|
onSelectSession = { onSelectSession(session.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeading("最近对话")
|
||||||
|
if (recentMessages.isEmpty()) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "还没有对话",
|
||||||
|
body = "手机、Web 和命令行入口发出的消息都会汇总到这里。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
recentMessages.forEach { message ->
|
||||||
|
val session = uiState.sessions.firstOrNull { it.id == message.sessionId }
|
||||||
|
GlobalMessageCard(
|
||||||
|
message = message,
|
||||||
|
sessionTitle = session?.title ?: message.sessionId,
|
||||||
|
onSelectSession = { onSelectSession(message.sessionId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeading("活跃任务")
|
||||||
|
if (activeTasks.isEmpty()) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = "目前没有活跃任务",
|
||||||
|
body = "一旦任何电脑端开始跑任务,进度会实时刷新到这里。",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
activeTasks.forEach { task ->
|
||||||
|
TaskCard(
|
||||||
|
task = task,
|
||||||
|
worker = task.assignedWorkerId?.let(workerById::get),
|
||||||
|
onPauseTask = {},
|
||||||
|
onResumeTask = {},
|
||||||
|
onCancelTask = {},
|
||||||
|
onRequeueTask = {},
|
||||||
|
actionsEnabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CreateSessionCard(
|
private fun CreateSessionCard(
|
||||||
onCreateSession: (String) -> Unit,
|
onCreateSession: (String) -> Unit,
|
||||||
@@ -816,6 +1009,7 @@ private fun BindDeviceCard(
|
|||||||
private fun WorkerCard(
|
private fun WorkerCard(
|
||||||
worker: WorkerNode,
|
worker: WorkerNode,
|
||||||
currentTask: TaskItem?,
|
currentTask: TaskItem?,
|
||||||
|
currentSession: Session?,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
onMarkOffline: () -> Unit,
|
onMarkOffline: () -> Unit,
|
||||||
@@ -858,6 +1052,12 @@ private fun WorkerCard(
|
|||||||
"当前任务:${it.title}"
|
"当前任务:${it.title}"
|
||||||
} ?: "当前任务:空闲",
|
} ?: "当前任务:空闲",
|
||||||
)
|
)
|
||||||
|
if (currentSession != null) {
|
||||||
|
Text(
|
||||||
|
"当前项目:${currentSession.title}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
|
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -875,6 +1075,158 @@ private fun WorkerCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClientActivityCard(
|
||||||
|
worker: WorkerNode,
|
||||||
|
task: TaskItem?,
|
||||||
|
session: Session?,
|
||||||
|
onSelectWorker: () -> Unit,
|
||||||
|
onSelectSession: () -> Unit,
|
||||||
|
) {
|
||||||
|
val health = workerHealth(worker)
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectWorker() },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(worker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("${worker.os} · ${health.label}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
StatusChip(label = health.label, tone = health.tone)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("当前项目:${session?.title ?: "暂无"}")
|
||||||
|
Text("当前任务:${task?.title ?: "空闲"}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(
|
||||||
|
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedButton(onClick = onSelectWorker) {
|
||||||
|
Text("看这台设备")
|
||||||
|
}
|
||||||
|
if (session != null) {
|
||||||
|
TextButton(onClick = onSelectSession) {
|
||||||
|
Text("打开项目")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectOverviewCard(
|
||||||
|
session: Session,
|
||||||
|
recentMessage: Message?,
|
||||||
|
activeTaskCount: Int,
|
||||||
|
workerNames: List<String>,
|
||||||
|
onSelectSession: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectSession() },
|
||||||
|
colors = CardDefaults.outlinedCardColors(
|
||||||
|
containerColor = if (session.status == "archived") {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(session.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("最近更新 ${formatRelative(session.updatedAt)}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
StatusChip(
|
||||||
|
label = if (session.status == "archived") "已归档" else "活跃",
|
||||||
|
tone = session.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.activeObjective.isNotBlank()) {
|
||||||
|
Text("当前目标:${session.activeObjective}")
|
||||||
|
}
|
||||||
|
if (recentMessage != null) {
|
||||||
|
Text(
|
||||||
|
"最近对话:${recentMessage.content}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
|
||||||
|
if (workerNames.isNotEmpty()) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(workerNames.joinToString(" · ").take(28)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GlobalMessageCard(
|
||||||
|
message: Message,
|
||||||
|
sessionTitle: String,
|
||||||
|
onSelectSession: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectSession() },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(sessionTitle, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(formatClock(message.createdAt), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${roleLabel(message.role)} · ${message.channel}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
message.content,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageCard(
|
private fun MessageCard(
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -939,6 +1291,7 @@ private fun TaskCard(
|
|||||||
onResumeTask: (String) -> Unit,
|
onResumeTask: (String) -> Unit,
|
||||||
onCancelTask: (String) -> Unit,
|
onCancelTask: (String) -> Unit,
|
||||||
onRequeueTask: (String) -> Unit,
|
onRequeueTask: (String) -> Unit,
|
||||||
|
actionsEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -976,6 +1329,7 @@ private fun TaskCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actionsEnabled) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
when (task.status) {
|
when (task.status) {
|
||||||
"paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") }
|
"paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") }
|
||||||
@@ -1000,6 +1354,7 @@ private fun TaskCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ApprovalCard(
|
private fun ApprovalCard(
|
||||||
@@ -1181,12 +1536,36 @@ private fun topBarSubtitle(
|
|||||||
selectedWorker: WorkerNode?,
|
selectedWorker: WorkerNode?,
|
||||||
): String {
|
): String {
|
||||||
return when (uiState.section) {
|
return when (uiState.section) {
|
||||||
MainSection.CONVERSATIONS -> selectedSession?.title ?: "在手机上直接和 Boss 对话"
|
MainSection.CONVERSATIONS -> selectedSession?.title ?: "查看所有客户端的项目与对话"
|
||||||
MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备"
|
MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备"
|
||||||
MainSection.SETTINGS -> "云端地址、健康状态与重排"
|
MainSection.SETTINGS -> "云端地址、健康状态与重排"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun syncStatusShortLabel(state: SyncConnectionState): String {
|
||||||
|
return when (state) {
|
||||||
|
SyncConnectionState.CONNECTING -> "连接中"
|
||||||
|
SyncConnectionState.LIVE -> "实时同步"
|
||||||
|
SyncConnectionState.RECONNECTING -> "重连中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncStatusLongLabel(state: SyncConnectionState): String {
|
||||||
|
return when (state) {
|
||||||
|
SyncConnectionState.CONNECTING -> "正在连接实时事件流,准备订阅电脑端和手机端的所有变化。"
|
||||||
|
SyncConnectionState.LIVE -> "项目、对话、审批和任务进度正在通过事件流实时刷新。"
|
||||||
|
SyncConnectionState.RECONNECTING -> "实时事件流暂时中断,系统正在重连,并且用轮询保持同步。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncTone(state: SyncConnectionState): String {
|
||||||
|
return when (state) {
|
||||||
|
SyncConnectionState.LIVE -> "live"
|
||||||
|
SyncConnectionState.CONNECTING -> "queued"
|
||||||
|
SyncConnectionState.RECONNECTING -> "lagging"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun roleLabel(role: String): String {
|
private fun roleLabel(role: String): String {
|
||||||
return when (role) {
|
return when (role) {
|
||||||
"user" -> "你"
|
"user" -> "你"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import site.hyzq.bossandroid.model.Session
|
|||||||
import site.hyzq.bossandroid.model.TaskItem
|
import site.hyzq.bossandroid.model.TaskItem
|
||||||
import site.hyzq.bossandroid.model.WorkerNode
|
import site.hyzq.bossandroid.model.WorkerNode
|
||||||
import site.hyzq.bossandroid.network.BossApi
|
import site.hyzq.bossandroid.network.BossApi
|
||||||
|
import site.hyzq.bossandroid.network.BossRealtimeSync
|
||||||
|
import site.hyzq.bossandroid.network.SyncConnectionState
|
||||||
|
|
||||||
enum class MainSection {
|
enum class MainSection {
|
||||||
CONVERSATIONS,
|
CONVERSATIONS,
|
||||||
@@ -32,6 +34,7 @@ enum class MainSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ConversationTab {
|
enum class ConversationTab {
|
||||||
|
OVERVIEW,
|
||||||
CHAT,
|
CHAT,
|
||||||
TASKS,
|
TASKS,
|
||||||
APPROVALS,
|
APPROVALS,
|
||||||
@@ -51,7 +54,7 @@ data class GeneratedWorkerCommand(
|
|||||||
data class BossUiState(
|
data class BossUiState(
|
||||||
val baseUrl: String = BossApi.DEFAULT_BASE_URL,
|
val baseUrl: String = BossApi.DEFAULT_BASE_URL,
|
||||||
val section: MainSection = MainSection.CONVERSATIONS,
|
val section: MainSection = MainSection.CONVERSATIONS,
|
||||||
val conversationTab: ConversationTab = ConversationTab.CHAT,
|
val conversationTab: ConversationTab = ConversationTab.OVERVIEW,
|
||||||
val sessions: List<Session> = emptyList(),
|
val sessions: List<Session> = emptyList(),
|
||||||
val messages: List<Message> = emptyList(),
|
val messages: List<Message> = emptyList(),
|
||||||
val tasks: List<TaskItem> = emptyList(),
|
val tasks: List<TaskItem> = emptyList(),
|
||||||
@@ -60,17 +63,21 @@ data class BossUiState(
|
|||||||
val events: List<BossEvent> = emptyList(),
|
val events: List<BossEvent> = emptyList(),
|
||||||
val selectedSessionId: String? = null,
|
val selectedSessionId: String? = null,
|
||||||
val selectedWorkerId: String? = null,
|
val selectedWorkerId: String? = null,
|
||||||
|
val syncConnectionState: SyncConnectionState = SyncConnectionState.CONNECTING,
|
||||||
val health: HealthPayload? = null,
|
val health: HealthPayload? = null,
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val generatedCommand: GeneratedWorkerCommand? = null,
|
val generatedCommand: GeneratedWorkerCommand? = null,
|
||||||
val notice: UiNotice? = null,
|
val notice: UiNotice? = null,
|
||||||
val lastSyncedAt: Long? = null,
|
val lastSyncedAt: Long? = null,
|
||||||
|
val lastEventAt: Long? = null,
|
||||||
|
val lastEventType: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class BossViewModel(
|
class BossViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
private val api = BossApi()
|
private val api = BossApi()
|
||||||
|
private val realtimeSync = BossRealtimeSync()
|
||||||
private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
private val refreshMutex = Mutex()
|
private val refreshMutex = Mutex()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
@@ -81,10 +88,12 @@ class BossViewModel(
|
|||||||
val uiState: StateFlow<BossUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<BossUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
private var streamJob: Job? = null
|
||||||
|
private var scheduledRefreshJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh(showSpinner = true)
|
refresh(showSpinner = true)
|
||||||
startPolling()
|
restartRealtimeSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectSection(section: MainSection) {
|
fun selectSection(section: MainSection) {
|
||||||
@@ -117,6 +126,7 @@ class BossViewModel(
|
|||||||
val sanitized = sanitizeBaseUrl(input)
|
val sanitized = sanitizeBaseUrl(input)
|
||||||
prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
|
prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
|
||||||
_uiState.update { it.copy(baseUrl = sanitized) }
|
_uiState.update { it.copy(baseUrl = sanitized) }
|
||||||
|
restartRealtimeSync()
|
||||||
refresh(showSpinner = true)
|
refresh(showSpinner = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +269,13 @@ class BossViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
streamJob?.cancel()
|
||||||
|
pollingJob?.cancel()
|
||||||
|
scheduledRefreshJob?.cancel()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
private fun runTaskMutation(
|
private fun runTaskMutation(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
successMessage: String,
|
successMessage: String,
|
||||||
@@ -365,6 +382,53 @@ class BossViewModel(
|
|||||||
|
|
||||||
private fun currentBaseUrl(): String = uiState.value.baseUrl
|
private fun currentBaseUrl(): String = uiState.value.baseUrl
|
||||||
|
|
||||||
|
private fun restartRealtimeSync() {
|
||||||
|
streamJob?.cancel()
|
||||||
|
stopPolling()
|
||||||
|
scheduledRefreshJob?.cancel()
|
||||||
|
streamJob = viewModelScope.launch {
|
||||||
|
realtimeSync.run(
|
||||||
|
baseUrl = currentBaseUrl(),
|
||||||
|
onStatusChanged = ::handleSyncStatusChange,
|
||||||
|
onEvent = ::handleRealtimeEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSyncStatusChange(state: SyncConnectionState) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(syncConnectionState = state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SyncConnectionState.LIVE) {
|
||||||
|
stopPolling()
|
||||||
|
} else if (state == SyncConnectionState.RECONNECTING) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRealtimeEvent(event: BossEvent) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
events = (listOf(event) + current.events).distinctBy { it.id }.take(MAX_EVENT_CACHE),
|
||||||
|
lastEventAt = System.currentTimeMillis(),
|
||||||
|
lastEventType = event.type,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scheduleRealtimeRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRealtimeRefresh() {
|
||||||
|
if (scheduledRefreshJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledRefreshJob = viewModelScope.launch {
|
||||||
|
delay(250)
|
||||||
|
refreshSnapshot(showSpinner = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
pollingJob = viewModelScope.launch {
|
pollingJob = viewModelScope.launch {
|
||||||
@@ -375,6 +439,11 @@ class BossViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun stopPolling() {
|
||||||
|
pollingJob?.cancel()
|
||||||
|
pollingJob = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun sanitizeBaseUrl(input: String): String {
|
private fun sanitizeBaseUrl(input: String): String {
|
||||||
val trimmed = input.trim().trimEnd('/')
|
val trimmed = input.trim().trimEnd('/')
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
@@ -465,7 +534,8 @@ class BossViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_BASE_URL = "boss_base_url"
|
private const val KEY_BASE_URL = "boss_base_url"
|
||||||
private const val POLL_INTERVAL_MS = 4_500L
|
private const val MAX_EVENT_CACHE = 300
|
||||||
|
private const val POLL_INTERVAL_MS = 2_000L
|
||||||
private const val PREFS_NAME = "boss_android"
|
private const val PREFS_NAME = "boss_android"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
DEFAULT_SERIAL="adb-A9TU024628000647-B0UNif._adb-tls-connect._tcp"
|
DEFAULT_SERIAL="adb-A9TU024628000647-B0UNif._adb-tls-connect._tcp"
|
||||||
|
DEFAULT_SDK_DIR="/opt/homebrew/share/android-commandlinetools"
|
||||||
PACKAGE_NAME="site.hyzq.bossandroid"
|
PACKAGE_NAME="site.hyzq.bossandroid"
|
||||||
|
ACTIVITY_NAME=".MainActivity"
|
||||||
APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk"
|
APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
TARGET_SERIAL="${BOSS_ANDROID_SERIAL:-$DEFAULT_SERIAL}"
|
TARGET_SERIAL="${BOSS_ANDROID_SERIAL:-$DEFAULT_SERIAL}"
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ Commands:
|
|||||||
launch Launch the Boss app on the Honor device
|
launch Launch the Boss app on the Honor device
|
||||||
build-install Build the debug APK and install it on the Honor device
|
build-install Build the debug APK and install it on the Honor device
|
||||||
screenshot Save a screenshot to /tmp/boss-honor-screen.png
|
screenshot Save a screenshot to /tmp/boss-honor-screen.png
|
||||||
|
ui-dump Dump the current UI hierarchy to /tmp/boss-honor-ui.xml
|
||||||
logcat Tail logcat for the Boss app only
|
logcat Tail logcat for the Boss app only
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
@@ -47,6 +50,12 @@ ensure_apk() {
|
|||||||
build_debug_apk() {
|
build_debug_apk() {
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/android-app"
|
cd "$ROOT_DIR/android-app"
|
||||||
|
if [[ -z "${ANDROID_HOME:-}" && -d "$DEFAULT_SDK_DIR" ]]; then
|
||||||
|
export ANDROID_HOME="$DEFAULT_SDK_DIR"
|
||||||
|
fi
|
||||||
|
if [[ -z "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_HOME:-}" ]]; then
|
||||||
|
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||||
|
fi
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,13 +80,13 @@ case "$command" in
|
|||||||
;;
|
;;
|
||||||
launch)
|
launch)
|
||||||
ensure_connected
|
ensure_connected
|
||||||
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
|
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
|
||||||
;;
|
;;
|
||||||
build-install)
|
build-install)
|
||||||
ensure_connected
|
ensure_connected
|
||||||
build_debug_apk
|
build_debug_apk
|
||||||
adb -s "$TARGET_SERIAL" install -r "$APK_PATH"
|
adb -s "$TARGET_SERIAL" install -r "$APK_PATH"
|
||||||
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
|
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
|
||||||
;;
|
;;
|
||||||
screenshot)
|
screenshot)
|
||||||
ensure_connected
|
ensure_connected
|
||||||
@@ -86,6 +95,13 @@ case "$command" in
|
|||||||
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-screen.png >/dev/null
|
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-screen.png >/dev/null
|
||||||
echo "/tmp/boss-honor-screen.png"
|
echo "/tmp/boss-honor-screen.png"
|
||||||
;;
|
;;
|
||||||
|
ui-dump)
|
||||||
|
ensure_connected
|
||||||
|
adb -s "$TARGET_SERIAL" shell uiautomator dump /sdcard/boss-honor-ui.xml >/dev/null
|
||||||
|
adb -s "$TARGET_SERIAL" pull /sdcard/boss-honor-ui.xml /tmp/boss-honor-ui.xml >/dev/null
|
||||||
|
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-ui.xml >/dev/null
|
||||||
|
echo "/tmp/boss-honor-ui.xml"
|
||||||
|
;;
|
||||||
logcat)
|
logcat)
|
||||||
ensure_connected
|
ensure_connected
|
||||||
adb -s "$TARGET_SERIAL" logcat --pid="$(adb -s "$TARGET_SERIAL" shell pidof "$PACKAGE_NAME")"
|
adb -s "$TARGET_SERIAL" logcat --pid="$(adb -s "$TARGET_SERIAL" shell pidof "$PACKAGE_NAME")"
|
||||||
|
|||||||
Reference in New Issue
Block a user