feat(android): 设计系统与五个页面重做,并补齐升级工单、邀请、看板

此前安卓只到"能登录、能看列表",且从没编译过。这轮把它做到与 Web 同构:

- Theme.kt 与 Tailwind 同一套色板与字号阶;新增 Widgets.kt(状态 pill、
  卡片、按钮、头像、空态、时间与 action 的中文映射、错误码文案)
- 新增 AppFrame.kt(顶栏工作区 + 未读角标 + 底部四 tab)与
  TaskDetailSheet / BoardScreen / MembersScreen
- 重写任务、工单、知识三页与登录、首页;任务详情里补上「升级成工单」
  和「沉淀成知识」——这两个入口此前只有 Web 有,是真正的功能欠账
- 看板不做拖拽:一列已占 76% 屏宽,拖拽命中率太差,改为点卡片选目标列
- 修运行期 bug:workspaces/members 按 List 解析而服务端返回 {items:[...]};
  消息区被手势条遮挡;升级工单候选人里出现自己;priority=0 显示"无"
- android/scripts/emulator.ps1 固化构建+装机+起模拟器;assembleDebug 出 APK

已在 API 35 模拟器上逐页跑通并截图(见 2.Design/screens/android-*.png)。
This commit is contained in:
dadao
2026-10-28 18:37:35 +08:00
parent 5be55fdccb
commit 52ec6474a9
13 changed files with 2402 additions and 812 deletions
@@ -1,8 +1,10 @@
package com.dadao.task
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -13,15 +15,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.WorkspaceScaffold
import com.dadao.task.ui.WorkspaceTab
class MainActivity : ComponentActivity() {
@@ -30,22 +37,37 @@ class MainActivity : ComponentActivity() {
setContent {
DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AppRoot()
AppRoot(joinUri = intent?.dataString)
}
}
}
}
}
private enum class Screen { Splash, Login, Home, Tasks, Tickets, Knowledge }
private enum class Screen { Splash, Login, Home, Workspace }
/** 从 https://dadaotask.com/join/<workspaceId>/<code> 里拆出工作区与邀请码。 */
private fun parseJoin(uri: String?): Pair<String, String>? {
if (uri.isNullOrBlank()) return null
val parts = uri.trimEnd('/').split("/")
val idx = parts.indexOf("join")
if (idx < 0 || parts.size < idx + 3) return null
val ws = parts[idx + 1]
val code = parts[idx + 2]
if (ws.length < 10 || code.length < 4) return null
return ws to code
}
@Composable
private fun AppRoot() {
val context = androidx.compose.ui.platform.LocalContext.current
private fun AppRoot(joinUri: String?) {
val context = LocalContext.current
val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
var pendingJoin by remember { mutableStateOf(parseJoin(joinUri)) }
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -60,8 +82,28 @@ private fun AppRoot() {
}
}
// 邀请链接:登录之后自动加入,加入完直接进那个工作区。
LaunchedEffect(screen, pendingJoin) {
val join = pendingJoin ?: return@LaunchedEffect
if (screen != Screen.Home) return@LaunchedEffect
runCatching { api.join(join.first, join.second) }
.onSuccess {
current = it
tab = WorkspaceTab.Tasks
pendingJoin = null
screen = Screen.Workspace
}
.onFailure { pendingJoin = null }
}
// 工单未读数:进工作区就拉一次,切页面时再刷。
LaunchedEffect(current?.id, tab) {
val ws = current ?: return@LaunchedEffect
unread = api.unreadCount(ws.id)
}
when (screen) {
Screen.Splash -> Unit // 校验中,空白过渡即可
Screen.Splash -> Unit
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen(
api = api,
@@ -71,37 +113,29 @@ private fun AppRoot() {
},
onOpenWorkspace = { ws ->
current = ws
screen = Screen.Tasks
tab = WorkspaceTab.Tasks
screen = Screen.Workspace
},
)
Screen.Tasks -> {
Screen.Workspace -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
TaskListScreen(
api = api,
WorkspaceScaffold(
workspace = ws,
onBack = { screen = Screen.Home },
onOpenTickets = { screen = Screen.Tickets },
onOpenKnowledge = { screen = Screen.Knowledge },
)
}
}
Screen.Tickets -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
}
}
Screen.Knowledge -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
KnowledgeScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
}
}
}
}
}