diff --git a/android/README.md b/android/README.md index 57747c6..1082164 100644 --- a/android/README.md +++ b/android/README.md @@ -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)、分类管理与配色 ``` ## 模拟器(本机搭建记录,别重复踩) diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 3869910..0e14210 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -44,10 +44,34 @@ private data class Envelope( @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, diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index 6087eaf..e32e511 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -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 = 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(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, + onDismiss: () -> Unit, + onChanged: () -> Unit, + onFail: (Throwable) -> Unit, +) { + val scope = rememberCoroutineScope() + var items by remember { mutableStateOf(categories) } + var draft by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var notice by remember { mutableStateOf(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)) diff --git a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt index 680e277..e115f28 100644 --- a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt @@ -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(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(null) } + var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(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(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(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 + } + }, + ) + } + } } diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index 6211a04..805107b 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -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, ) diff --git a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt index 1c3bb19..61daefc 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/dadao/task/ui/Widgets.kt b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt index 5a8cced..50b6b34 100644 --- a/android/app/src/main/java/com/dadao/task/ui/Widgets.kt +++ b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt @@ -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(