feat(android): 登录改手机号/邮箱+密码并支持邮箱注册,知识分类可改名配色删除
CI / backend (push) Canceled after 0s

- 生产已关短信登录,登录页先读 /auth/methods 再决定入口:默认手机号/邮箱+密码,
  后端开着短信时才出现验证码登录;邮箱账号 phone 为空,User.phone 改可空
- 新增邮箱验证码注册(一步建号即登录态)
- 知识:分类色点/选中描边/加载态,管理面板可改名、选色(10 色板或手填 hex)、
  建子分类、删除(知识回到未分类),卡片上边线用分类色
- 正文 16sp / 行高 1.7,列表标题 SemiBold,与 Web 阅读排版对齐
- README 更新登录方式与结构说明
This commit is contained in:
2026-11-05 15:55:31 +08:00
committed by steven
parent 2c338490e5
commit 29805bd61e
7 changed files with 1061 additions and 96 deletions
+5 -3
View File
@@ -79,7 +79,9 @@ release 仍然只允许 `network_security_config.xml` 里列出的域,别把
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。
首次打开 → **手机号或邮箱 + 密码**登录(短信验证码登录默认关闭;开发环境需要验证码登录时,
后端设 `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`,登录页会自动出现「验证码登录」切换)。
没有账号就点「用邮箱注册」:收邮箱验证码 → 设密码 → 直接进工作区。
## 结构
@@ -91,10 +93,10 @@ app/src/main/java/com/dadao/task/
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码
├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
```
## 模拟器(本机搭建记录,别重复踩)
@@ -44,10 +44,34 @@ private data class Envelope<T>(
@Serializable
data class User(
val id: String,
val phone: String,
/**
* 邮箱注册的账号没有手机号;有手机号的也只给脱敏值(138****8000),
* 与后端 `_user_out` 一致。所以这里必须可空——否则邮箱账号一登录就解析失败。
*/
val phone: String? = null,
val email: String? = null,
@SerialName("email_verified") val emailVerified: Boolean = false,
@SerialName("has_password") val hasPassword: Boolean = false,
val nickname: String = "",
)
/**
* 这台服务现在开放哪些登录入口(后端 `/auth/methods`)。
*
* 生产早就把短信登录关了,客户端不能"照着界面上有就点"——
* 点进去才发现这条路关着最气人,所以登录页先问一次再渲染。
*/
@Serializable
data class AuthMethods(
val password: Boolean = true,
@SerialName("email_password") val emailPassword: Boolean = true,
@SerialName("sms_code") val smsCode: Boolean = false,
@SerialName("email_register") val emailRegister: Boolean = false,
) {
/** 一条路都没有时至少让密码登录留在界面上,免得白屏。 */
val usable: Boolean get() = password || emailPassword || smsCode
}
@Serializable
data class Tokens(
@SerialName("access_token") val accessToken: String,
@@ -256,9 +280,12 @@ data class AppRelease(
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
@Serializable
data class KnowledgeCategory( val id: String,
data class KnowledgeCategory(
val id: String,
val name: String = "",
@SerialName("parent_id") val parentId: String? = null,
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
val color: String? = null,
val depth: Int = 1,
@SerialName("sort_order") val sortOrder: Int = 0,
val version: Int = 1,
@@ -324,6 +351,25 @@ private data class SmsRequest(val phone: String)
@Serializable
private data class LoginRequest(val phone: String, val code: String)
/** 手机号或邮箱二选一:只发一个字段,另一个为 null 时不会被序列化出去。 */
@Serializable
private data class PasswordLoginRequest(
val phone: String? = null,
val email: String? = null,
val password: String,
)
@Serializable
private data class EmailCodeRequest(val email: String)
@Serializable
private data class RegisterRequest(
val email: String,
val code: String,
val password: String,
val nickname: String? = null,
)
@Serializable
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@@ -370,6 +416,27 @@ private data class KnowledgeCreateRequest(
@Serializable
private data class KnowledgeActionRequest(val action: String)
@Serializable
private data class CategoryCreateRequest(
val name: String,
@SerialName("parent_id") val parentId: String? = null,
@SerialName("sort_order") val sortOrder: Int = 0,
val color: String? = null,
)
/**
* 分类改名 / 改色。`version` 是乐观锁,必填。
*
* color 传空串表示"取消上色",不传(null)表示"别动颜色"——
* 靠 `explicitNulls = false` 把 null 字段整个省掉来区分这两件事。
*/
@Serializable
private data class CategoryPatchRequest(
val version: Int,
val name: String? = null,
val color: String? = null,
)
@Serializable
private data class TicketCreateRequest(
val title: String,
@@ -475,6 +542,58 @@ class Api(private val tokens: TokenStore) {
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
/** 这台服务开放哪些登录入口;登录页拿到之后才决定渲染哪几个 tab。 */
suspend fun authMethods(): AuthMethods = call(path = "/auth/methods")
/**
* 手机号 / 邮箱 + 密码登录。
*
* 账号里有没有 `@` 决定发哪个字段:后端两个都收,但带着一个空字符串 phone
* 会被 pattern 校验挡掉,所以这里只发一个。
*/
suspend fun loginPassword(account: String, password: String): LoginResult {
val who = account.trim()
val body = if (who.contains('@')) {
PasswordLoginRequest(email = who, password = password)
} else {
PasswordLoginRequest(phone = who, password = password)
}
val result: LoginResult = call(
path = "/auth/login/password",
body = json.encodeToString(body),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
/** 邮箱注册验证码。生产真发信;`dev_code` 只在开了 EMAIL_CODE_DEV_ECHO 的本机才回显。 */
suspend fun sendEmailCode(email: String): JsonElement? = call(
path = "/auth/email/code",
body = json.encodeToString(EmailCodeRequest(email.trim())),
)
/** 邮箱 + 验证码 + 密码一步建号,注册完就是登录态(服务端直接给令牌)。 */
suspend fun register(
email: String,
code: String,
password: String,
nickname: String? = null,
): LoginResult {
val result: LoginResult = call(
path = "/auth/register",
body = json.encodeToString(
RegisterRequest(
email = email.trim(),
code = code.trim(),
password = password,
nickname = nickname?.trim()?.ifBlank { null },
),
),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
/**
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
* (`backend/app/api/workspaces.py` 的 list_workspaces),
@@ -736,6 +855,50 @@ class Api(private val tokens: TokenStore) {
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
/** 建分类;同层重名会被服务端拒(4006)。color 不传就是不上色。 */
suspend fun createKnowledgeCategory(
workspaceId: String,
name: String,
parentId: String? = null,
color: String? = null,
): KnowledgeCategory = call(
path = "/workspaces/$workspaceId/knowledge/categories",
body = json.encodeToString(
CategoryCreateRequest(name = name.trim(), parentId = parentId, color = color),
),
auth = true,
)
/**
* 改名 / 改色,必须带 version。
*
* color 传 `""` 表示取消上色,传 null 表示别动颜色——这个区别由
* `explicitNulls = false`(null 字段直接不序列化)兜住。
*/
suspend fun patchKnowledgeCategory(
workspaceId: String,
categoryId: String,
version: Int,
name: String? = null,
color: String? = null,
): KnowledgeCategory = call(
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId",
body = json.encodeToString(CategoryPatchRequest(version, name?.trim(), color)),
auth = true,
method = "PATCH",
)
/** 删分类。force=true 才能删有内容的分类,里面的知识会退成未分类。 */
suspend fun deleteKnowledgeCategory(
workspaceId: String,
categoryId: String,
force: Boolean = false,
): JsonElement = call(
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId?force=$force",
auth = true,
method = "DELETE",
)
/** 手记:没有来源的知识,得自己写清楚。 */
suspend fun createKnowledge(
workspaceId: String,
@@ -5,10 +5,13 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,15 +21,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
@@ -39,6 +46,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@@ -87,6 +96,76 @@ private fun parseTags(raw: String): List<String> =
private fun catLabel(cat: KnowledgeCategory): String =
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
/**
* 与 Web 端同一套调色板。
*
* 分类没设颜色时按 id 稳定挑一个:老分类不会全灰,也不会每次刷新换个颜色
* (用户按颜色记分类,颜色一变等于记忆作废)。
*/
private val categoryPalette = listOf(
"#4F46E5", "#0EA5E9", "#10B981", "#F59E0B", "#EF4444",
"#EC4899", "#8B5CF6", "#14B8A6", "#64748B", "#84CC16",
)
private val hexColorRe = Regex("^#[0-9A-Fa-f]{6}$")
/** 分类色 → Compose Color。没设色就按 id 稳定挑调色板里的一个。 */
private fun categoryColorOf(id: String?, color: String?): Color? {
val raw = color?.takeIf { hexColorRe.matches(it) } ?: id?.let { key ->
var hash = 0
key.forEach { hash = (hash * 31 + it.code) % 9973 }
categoryPalette[hash % categoryPalette.size]
} ?: return null
return runCatching { Color(android.graphics.Color.parseColor(raw)) }.getOrNull()
}
private fun categoryColorOf(cat: KnowledgeCategory?): Color? =
cat?.let { categoryColorOf(it.id, it.color) }
/** 带色点的分类 chip:颜色是"这个分类是哪个"最快的识别方式。 */
@Composable
private fun CategoryChip(
label: String,
color: Color?,
selected: Boolean,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.clickable { onClick() },
color = if (selected) PrimarySoft else Color.White,
contentColor = if (selected) Primary else InkSoft,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(
if (selected) 1.5.dp else 1.dp,
if (selected) Primary else Line,
),
) {
Row(
Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (color != null) {
CategoryDot(color, 6.dp)
Spacer(Modifier.width(5.dp))
}
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun LoadingInline(text: String = "载入中…") {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = Primary,
)
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.labelSmall, color = Primary)
}
}
@Composable
fun KnowledgeScreen(
api: Api,
@@ -106,6 +185,7 @@ fun KnowledgeScreen(
var adding by remember { mutableStateOf(false) }
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
var catFilter by remember { mutableStateOf<String?>(null) }
var managing by remember { mutableStateOf(false) }
suspend fun loadCategories() {
runCatching { api.knowledgeCategories(workspace.id) }
@@ -176,27 +256,26 @@ fun KnowledgeScreen(
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SurfaceChip(
text = "全部分类",
selected = catFilter == null,
onClick = { catFilter = null },
)
SurfaceChip(
text = "未分类",
selected = catFilter == "none",
onClick = { catFilter = "none" },
)
CategoryChip("全部分类", null, catFilter == null) { catFilter = null }
CategoryChip("未分类", null, catFilter == "none") { catFilter = "none" }
categories.forEach { cat ->
SurfaceChip(
text = catLabel(cat),
selected = catFilter == cat.id,
onClick = { catFilter = cat.id },
)
CategoryChip(catLabel(cat), categoryColorOf(cat), catFilter == cat.id) {
catFilter = cat.id
}
}
if (loading) {
Spacer(Modifier.width(4.dp))
LoadingInline()
}
}
Spacer(Modifier.height(10.dp))
GhostButton("记一条", onClick = { adding = true })
Row(verticalAlignment = Alignment.CenterVertically) {
GhostButton("记一条", onClick = { adding = true })
Spacer(Modifier.width(8.dp))
GhostButton("管理分类", onClick = { managing = true })
}
}
}
@@ -221,7 +300,12 @@ fun KnowledgeScreen(
}
items(docs, key = { it.id }) { doc ->
SoftCard(padding = 12.dp, onClick = { open = doc }) {
SoftCard(
padding = 12.dp,
accent = categoryColorOf(doc.category),
modifier = Modifier.alpha(if (loading) 0.45f else 1f),
onClick = { open = doc },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SoftPill(docTypeLabel(doc.knowledge.docType))
Spacer(Modifier.width(6.dp))
@@ -235,16 +319,27 @@ fun KnowledgeScreen(
Text(
doc.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
val meta = buildList {
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
doc.tags.forEach { add("#${it.name}") }
// 分类用同色圆点带出来:卡片上边线看颜色,这里看名字,两处一致
doc.category?.let { cat ->
Row(verticalAlignment = Alignment.CenterVertically) {
categoryColorOf(cat)?.let {
CategoryDot(it, 6.dp)
Spacer(Modifier.width(5.dp))
}
Text(
cat.fullPath.ifBlank { cat.name },
style = MaterialTheme.typography.labelSmall,
color = InkSoft,
)
}
Spacer(Modifier.height(4.dp))
}
if (meta.isNotEmpty()) {
if (doc.tags.isNotEmpty()) {
Text(
meta.joinToString(" "),
doc.tags.joinToString(" ") { "#${it.name}" },
style = MaterialTheme.typography.labelSmall,
color = Primary,
)
@@ -307,6 +402,373 @@ fun KnowledgeScreen(
onFail = { fail(it) },
)
}
if (managing) {
CategoryManagerDialog(
api = api,
workspaceId = workspace.id,
categories = categories,
onDismiss = { managing = false },
onChanged = {
scope.launch {
loadCategories()
reload()
}
},
onFail = { fail(it) },
)
}
}
/** 分类编辑中的草稿:id 为空表示"还没建出来的新分类"。 */
private data class CategoryDraft(
val id: String?,
val parentId: String?,
val name: String,
val color: String?,
val version: Int,
val depth: Int,
)
private fun hexToColor(hex: String): Color =
runCatching { Color(android.graphics.Color.parseColor(hex)) }.getOrDefault(InkMuted)
/**
* 分类管理:改名 / 改色 / 建子分类 / 删除。
*
* 手机上不放浮层菜单——点分类行直接进编辑,所有动作都在同一张卡片里,
* 免得"改名在哪、删在哪"到处找。
*/
@Composable
private fun CategoryManagerDialog(
api: Api,
workspaceId: String,
categories: List<KnowledgeCategory>,
onDismiss: () -> Unit,
onChanged: () -> Unit,
onFail: (Throwable) -> Unit,
) {
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf(categories) }
var draft by remember { mutableStateOf<CategoryDraft?>(null) }
var pendingDelete by remember { mutableStateOf<KnowledgeCategory?>(null) }
var busy by remember { mutableStateOf(false) }
var notice by remember { mutableStateOf<String?>(null) }
suspend fun refresh() {
runCatching { api.knowledgeCategories(workspaceId) }
.onSuccess {
items = it.items
onChanged()
}
.onFailure { onFail(it) }
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
notice = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
}
fun save() {
val d = draft ?: return
val name = d.name.trim()
if (name.isEmpty()) {
notice = "先给分类起个名字"
return
}
if (d.color != null && !hexColorRe.matches(d.color)) {
notice = "颜色要用 #RRGGBB,例如 #4F46E5"
return
}
scope.launch {
busy = true
// 没设色时传空串,表示"这个分类不上色";有颜色就传它
val color = d.color ?: ""
runCatching {
if (d.id == null) {
api.createKnowledgeCategory(workspaceId, name, d.parentId, color.ifBlank { null })
} else {
api.patchKnowledgeCategory(workspaceId, d.id, d.version, name, color)
}
}
.onSuccess {
notice = if (d.id == null) "分类已建好" else "已保存"
draft = null
refresh()
}
.onFailure { fail(it) }
busy = false
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
Modifier
.fillMaxWidth(0.94f)
.fillMaxHeight(0.9f)
.background(Paper, RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"分类管理",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
GhostButton("关闭", onClick = onDismiss)
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
GhostButton(
"新建一级分类",
enabled = !busy,
onClick = {
notice = null
draft = CategoryDraft(null, null, "", null, 1, 1)
},
)
Spacer(Modifier.width(8.dp))
Text(
"分类颜色决定卡片上边线,最多 3 层",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
}
val hint = notice
if (hint != null) {
Spacer(Modifier.height(8.dp))
SoftPill(hint, Primary, PrimarySoft, Modifier.fillMaxWidth())
}
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
val editing = draft
if (editing != null) {
CategoryEditorCard(
draft = editing,
busy = busy,
onChange = { draft = it },
onSave = { save() },
onAddChild = {
draft = CategoryDraft(
// 新分类还没有 id,挂到当前行的父级下;已有分类就挂在它自己下
id = null,
parentId = editing.id,
name = "",
color = editing.color,
version = 1,
depth = editing.depth + 1,
)
},
onDelete = {
val id = editing.id
val cat = items.firstOrNull { it.id == id }
if (cat != null) pendingDelete = cat
},
onCancel = { draft = null },
)
Spacer(Modifier.height(12.dp))
}
if (items.isEmpty()) {
Text(
"还没有分类。建一个吧——比如「研发」「客户」「流程」。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(vertical = 10.dp),
)
}
items.forEach { cat ->
Row(
Modifier
.fillMaxWidth()
.clickable {
notice = null
draft = CategoryDraft(
cat.id,
cat.parentId,
cat.name,
cat.color,
cat.version,
cat.depth,
)
}
.padding(start = ((cat.depth - 1) * 14).dp, top = 9.dp, bottom = 9.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CategoryDot(categoryColorOf(cat) ?: LineStrong, 9.dp)
Spacer(Modifier.width(8.dp))
Text(
cat.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Text(
"${cat.docCount} 条",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(8.dp))
Text(
"编辑",
style = MaterialTheme.typography.labelSmall,
color = Primary,
)
}
DividerLine()
}
Spacer(Modifier.height(20.dp))
}
}
}
val victim = pendingDelete
if (victim != null) {
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("删除「${victim.name}」?") },
text = {
Text(
if (victim.docCount > 0) {
"这个分类下还有 ${victim.docCount} 条知识,它们会回到「未分类」,知识本身不会被删。"
} else {
"这个分类下没有知识,可以放心删。"
},
)
},
confirmButton = {
TextButton(onClick = {
val target = victim
pendingDelete = null
scope.launch {
busy = true
runCatching {
api.deleteKnowledgeCategory(workspaceId, target.id, force = true)
}
.onSuccess {
notice = "已删除「${target.name}」"
draft = null
refresh()
}
.onFailure { fail(it) }
busy = false
}
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("取消") }
},
containerColor = Paper,
)
}
}
/** 一个分类的编辑卡:名字、颜色(色板 + 手填 hex)、建子分类、删除。 */
@Composable
private fun CategoryEditorCard(
draft: CategoryDraft,
busy: Boolean,
onChange: (CategoryDraft) -> Unit,
onSave: () -> Unit,
onAddChild: () -> Unit,
onDelete: () -> Unit,
onCancel: () -> Unit,
) {
SoftCard(padding = 12.dp, accent = categoryColorOf(draft.id, draft.color)) {
Text(
if (draft.id == null) "新建分类" else "编辑「${draft.name.ifBlank { "未命名" }}」",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draft.name,
onValueChange = { onChange(draft.copy(name = it.take(40))) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("分类名,同层不能重名", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
SectionLabel("颜色(卡片上边线用它)")
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
categoryPalette.forEach { hex ->
val picked = draft.color?.equals(hex, ignoreCase = true) == true
Box(
Modifier
.size(26.dp)
.clip(CircleShape)
.background(hexToColor(hex))
.border(
if (picked) 2.5.dp else 1.dp,
if (picked) Ink else Line,
CircleShape,
)
.clickable { onChange(draft.copy(color = hex)) },
contentAlignment = Alignment.Center,
) {
if (picked) Text("✓", style = MaterialTheme.typography.labelSmall, color = Color.White)
}
}
Surface(
modifier = Modifier.clickable { onChange(draft.copy(color = null)) },
color = if (draft.color == null) PrimarySoft else Color.White,
contentColor = if (draft.color == null) Primary else InkSoft,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, if (draft.color == null) Primary else Line),
) {
Text(
"不上色",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draft.color.orEmpty(),
onValueChange = { onChange(draft.copy(color = it.trim().take(7).ifBlank { null })) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("也可以手填 #RRGGBB", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
PrimaryButton("保存", enabled = !busy, onClick = onSave)
Spacer(Modifier.width(8.dp))
GhostButton("取消", enabled = !busy, onClick = onCancel)
Spacer(Modifier.width(8.dp))
if (draft.id != null && draft.depth < 3) {
GhostButton("加子分类", enabled = !busy, onClick = onAddChild)
Spacer(Modifier.width(8.dp))
}
if (draft.id != null) {
TextButton(enabled = !busy, onClick = onDelete) {
Text("删除", color = Rust)
}
}
}
}
}
/**
@@ -327,8 +789,9 @@ private fun KnowledgeBody(text: String) {
factory = { ctx ->
android.widget.TextView(ctx).apply {
setTextIsSelectable(true)
textSize = 14f
setLineSpacing(0f, 1.5f)
// 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
textSize = 16f
setLineSpacing(0f, 1.7f)
setTextColor(android.graphics.Color.parseColor("#1E293B"))
markwon.setMarkdown(this, text)
}
@@ -574,7 +1037,8 @@ private fun KnowledgeDetailDialog(
},
text = {
Column(
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
// 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚
Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()),
) {
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp))
@@ -6,11 +6,12 @@ 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.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.AuthMethods
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** 手机号 11 位、邮箱只要有 @ 就放行——最终格式由服务端判,前端别提前拦。 */
private fun looksLikeEmail(input: String): Boolean = input.contains('@')
private fun accountReady(account: String): Boolean {
val text = account.trim()
return if (looksLikeEmail(text)) text.length >= 6 else text.length == 11
}
@Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
val scope = rememberCoroutineScope()
// 登录入口以服务端为准:生产早就关了短信登录,界面不能还摆着一条走不通的路。
var methods by remember { mutableStateOf<AuthMethods?>(null) }
var mode by remember { mutableStateOf("password") }
var account by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var registerOpen by remember { mutableStateOf(false) }
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
var serverOpen by remember { mutableStateOf(false) }
@@ -86,6 +110,15 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
}
LaunchedEffect(Unit) {
runCatching { api.authMethods() }
.onSuccess {
methods = it
// 只有短信这一条路时,默认切到它,别让人对着密码框发呆
if (!it.password && !it.emailPassword && it.smsCode) mode = "code"
}
}
LaunchedEffect(seconds) {
if (seconds > 0) {
delay(1000)
@@ -93,6 +126,25 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
}
fun report(e: Throwable, fallback: String) {
val c = (e as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
}
fun submitPassword() {
scope.launch {
busy = true
runCatching { api.loginPassword(account, password) }
.onSuccess {
error = null
onLoggedIn()
}
.onFailure { report(it, "登录没成功") }
busy = false
}
}
fun send() {
scope.launch {
busy = true
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
error = null
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
}
.onFailure { report(it, "验证码没发出去") }
busy = false
}
}
fun submit() {
fun submitCode() {
scope.launch {
busy = true
runCatching { api.login(phone.trim(), code.trim()) }
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
error = null
onLoggedIn()
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
}
.onFailure { report(it, "登录没成功") }
busy = false
}
}
val smsAvailable = methods?.smsCode == true
val registerAvailable = methods?.emailRegister == true
Column(
Modifier
.fillMaxSize()
@@ -160,39 +207,45 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
Text("登录", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp))
Text(
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
if (smsAvailable) {
"手机号或邮箱 + 密码,也可以收验证码登录。"
} else {
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
},
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(22.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
// 两条路都可以走时才给切换 tab,只有一条就别摆出来当摆设
if (smsAvailable) {
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SurfaceChip(
text = "密码登录",
selected = mode == "password",
onClick = { mode = "password"; error = null },
)
SurfaceChip(
text = "验证码登录",
selected = mode == "code",
onClick = { mode = "code"; error = null },
)
}
}
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(18.dp))
if (mode == "password") {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
value = account,
onValueChange = { account = it.take(80) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号或邮箱") },
placeholder = { Text("13800138000 或 you@example.com", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
@@ -200,23 +253,76 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "获取验证码",
enabled = phone.length == 11 && seconds == 0 && !busy,
onClick = { send() },
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it.take(64) },
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
} else {
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
"开发环境验证码:$hint",
Primary,
PrimarySoft,
Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "获取验证码",
enabled = phone.length == 11 && seconds == 0 && !busy,
onClick = { send() },
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
"开发环境验证码:$hint",
Primary,
PrimarySoft,
Modifier.fillMaxWidth(),
)
}
}
val notice = error
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
Spacer(Modifier.height(22.dp))
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submit() },
)
if (mode == "password") {
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = accountReady(account) && password.isNotEmpty() && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submitPassword() },
)
} else {
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submitCode() },
)
}
if (registerAvailable) {
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"还没有账号?",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
TextButton(
enabled = !busy,
onClick = { error = null; registerOpen = true },
) { Text("用邮箱注册") }
}
}
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
Spacer(Modifier.height(40.dp))
}
if (registerOpen) {
RegisterDialog(
api = api,
onDismiss = { registerOpen = false },
onRegistered = {
registerOpen = false
onLoggedIn()
},
)
}
}
/**
* 邮箱注册:验证码 + 密码,一步建号。
*
* 注册完服务端直接回令牌,所以这里不再跳回登录页——落地即登录,少一次输入。
*/
@Composable
private fun RegisterDialog(api: Api, onDismiss: () -> Unit, onRegistered: () -> Unit) {
val scope = rememberCoroutineScope()
var email by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(seconds) {
if (seconds > 0) {
delay(1000)
seconds -= 1
}
}
fun fail(e: Throwable, fallback: String) {
val c = (e as? ApiException)?.code ?: 0
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
Modifier
.fillMaxWidth(0.94f)
.fillMaxHeight(0.9f)
.background(Paper, RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"注册",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
GhostButton("取消", onClick = onDismiss)
}
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
Text(
"用邮箱收一个验证码,设好密码就是账号。邮箱也是以后找回密码的唯一途径,别填错。",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it.take(80) },
modifier = Modifier.fillMaxWidth(),
label = { Text("邮箱") },
placeholder = { Text("you@example.com", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
singleLine = true,
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "发验证码",
enabled = email.contains('@') && seconds == 0 && !busy,
onClick = {
scope.launch {
busy = true
runCatching { api.sendEmailCode(email) }
.onSuccess {
seconds = 60
devCode = it?.jsonObject
?.get("dev_code")
?.jsonPrimitive
?.content
error = null
}
.onFailure { fail(it, "验证码没发出去") }
busy = false
}
},
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill("开发环境验证码:$hint", Primary, PrimarySoft, Modifier.fillMaxWidth())
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it.take(64) },
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
placeholder = { Text("至少 8 位,别用生日", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it.take(32) },
modifier = Modifier.fillMaxWidth(),
label = { Text("称呼(可不填)") },
placeholder = { Text("别人在成员列表里看到的名字", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
val notice = error
if (notice != null) {
Spacer(Modifier.height(10.dp))
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
}
}
Spacer(Modifier.height(12.dp))
PrimaryButton(
text = if (busy) "请稍候…" else "注册并登录",
enabled = email.contains('@') && code.length >= 4 && password.isNotEmpty() && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = {
scope.launch {
busy = true
runCatching { api.register(email, code, password, nickname) }
.onSuccess { onRegistered() }
.onFailure { fail(it, "注册没成功") }
busy = false
}
},
)
}
}
}
@@ -299,7 +299,7 @@ private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
Text(
item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
color = if (item.isDone) InkMuted else Ink,
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
)
@@ -164,7 +164,7 @@ fun TicketsScreen(
Text(
ticket.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Row(
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -96,27 +97,46 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
}
}
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
/**
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
*
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
*/
@Composable
fun SoftCard(
modifier: Modifier = Modifier,
padding: Dp = 14.dp,
accent: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(14.dp)
val base = modifier
.fillMaxWidth()
.border(1.dp, Line, RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
.clip(shape)
.border(1.dp, Line, shape)
.background(Color.White, shape)
Surface(
modifier = if (onClick != null) base.clickable { onClick() } else base,
color = Color.White,
shape = RoundedCornerShape(14.dp),
shape = shape,
) {
Column(modifier = Modifier.padding(padding), content = content)
Column {
if (accent != null) {
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
}
Column(modifier = Modifier.padding(padding), content = content)
}
}
}
/** 分类色的小圆点:侧栏、筛选 chip、卡片 meta 里到处都用它。 */
@Composable
fun CategoryDot(color: Color, size: Dp = 8.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(color, CircleShape))
}
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(