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 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) │ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
└── ui/ └── ui/
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板 ├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码 ├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码 ├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论 ├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin) └── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
``` ```
## 模拟器(本机搭建记录,别重复踩) ## 模拟器(本机搭建记录,别重复踩)
@@ -44,10 +44,34 @@ private data class Envelope<T>(
@Serializable @Serializable
data class User( data class User(
val id: String, 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 = "", 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 @Serializable
data class Tokens( data class Tokens(
@SerialName("access_token") val accessToken: String, @SerialName("access_token") val accessToken: String,
@@ -256,9 +280,12 @@ data class AppRelease(
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */ /** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
@Serializable @Serializable
data class KnowledgeCategory( val id: String, data class KnowledgeCategory(
val id: String,
val name: String = "", val name: String = "",
@SerialName("parent_id") val parentId: String? = null, @SerialName("parent_id") val parentId: String? = null,
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
val color: String? = null,
val depth: Int = 1, val depth: Int = 1,
@SerialName("sort_order") val sortOrder: Int = 0, @SerialName("sort_order") val sortOrder: Int = 0,
val version: Int = 1, val version: Int = 1,
@@ -324,6 +351,25 @@ private data class SmsRequest(val phone: String)
@Serializable @Serializable
private data class LoginRequest(val phone: String, val code: String) 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 @Serializable
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@@ -370,6 +416,27 @@ private data class KnowledgeCreateRequest(
@Serializable @Serializable
private data class KnowledgeActionRequest(val action: String) 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 @Serializable
private data class TicketCreateRequest( private data class TicketCreateRequest(
val title: String, val title: String,
@@ -475,6 +542,58 @@ class Api(private val tokens: TokenStore) {
suspend fun me(): MeResult = call(path = "/auth/me", auth = true) 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": [...]}` 而不是裸数组 * 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
* (`backend/app/api/workspaces.py` 的 list_workspaces), * (`backend/app/api/workspaces.py` 的 list_workspaces),
@@ -736,6 +855,50 @@ class Api(private val tokens: TokenStore) {
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage = suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true) 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( suspend fun createKnowledge(
workspaceId: String, workspaceId: String,
@@ -5,10 +5,13 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
@@ -39,6 +46,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -87,6 +96,76 @@ private fun parseTags(raw: String): List<String> =
private fun catLabel(cat: KnowledgeCategory): String = private fun catLabel(cat: KnowledgeCategory): String =
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name "·".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 @Composable
fun KnowledgeScreen( fun KnowledgeScreen(
api: Api, api: Api,
@@ -106,6 +185,7 @@ fun KnowledgeScreen(
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类) // null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
var catFilter by remember { mutableStateOf<String?>(null) } var catFilter by remember { mutableStateOf<String?>(null) }
var managing by remember { mutableStateOf(false) }
suspend fun loadCategories() { suspend fun loadCategories() {
runCatching { api.knowledgeCategories(workspace.id) } runCatching { api.knowledgeCategories(workspace.id) }
@@ -176,27 +256,26 @@ fun KnowledgeScreen(
Row( Row(
modifier = Modifier.horizontalScroll(rememberScrollState()), modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
SurfaceChip( CategoryChip("全部分类", null, catFilter == null) { catFilter = null }
text = "全部分类", CategoryChip("未分类", null, catFilter == "none") { catFilter = "none" }
selected = catFilter == null,
onClick = { catFilter = null },
)
SurfaceChip(
text = "未分类",
selected = catFilter == "none",
onClick = { catFilter = "none" },
)
categories.forEach { cat -> categories.forEach { cat ->
SurfaceChip( CategoryChip(catLabel(cat), categoryColorOf(cat), catFilter == cat.id) {
text = catLabel(cat), catFilter = cat.id
selected = catFilter == cat.id, }
onClick = { catFilter = cat.id }, }
) if (loading) {
Spacer(Modifier.width(4.dp))
LoadingInline()
} }
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
GhostButton("记一条", onClick = { adding = true }) 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 -> 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) { Row(verticalAlignment = Alignment.CenterVertically) {
SoftPill(docTypeLabel(doc.knowledge.docType)) SoftPill(docTypeLabel(doc.knowledge.docType))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@@ -235,16 +319,27 @@ fun KnowledgeScreen(
Text( Text(
doc.title, doc.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
val meta = buildList { // 分类用同色圆点带出来:卡片上边线看颜色,这里看名字,两处一致
doc.category?.let { add(it.fullPath.ifBlank { it.name }) } doc.category?.let { cat ->
doc.tags.forEach { add("#${it.name}") } Row(verticalAlignment = Alignment.CenterVertically) {
categoryColorOf(cat)?.let {
CategoryDot(it, 6.dp)
Spacer(Modifier.width(5.dp))
} }
if (meta.isNotEmpty()) {
Text( Text(
meta.joinToString(" "), cat.fullPath.ifBlank { cat.name },
style = MaterialTheme.typography.labelSmall,
color = InkSoft,
)
}
Spacer(Modifier.height(4.dp))
}
if (doc.tags.isNotEmpty()) {
Text(
doc.tags.joinToString(" ") { "#${it.name}" },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Primary, color = Primary,
) )
@@ -307,6 +402,373 @@ fun KnowledgeScreen(
onFail = { fail(it) }, 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 -> factory = { ctx ->
android.widget.TextView(ctx).apply { android.widget.TextView(ctx).apply {
setTextIsSelectable(true) setTextIsSelectable(true)
textSize = 14f // 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
setLineSpacing(0f, 1.5f) textSize = 16f
setLineSpacing(0f, 1.7f)
setTextColor(android.graphics.Color.parseColor("#1E293B")) setTextColor(android.graphics.Color.parseColor("#1E293B"))
markwon.setMarkdown(this, text) markwon.setMarkdown(this, text)
} }
@@ -574,7 +1037,8 @@ private fun KnowledgeDetailDialog(
}, },
text = { text = {
Column( 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) Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp)) 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp 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.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
import com.dadao.task.data.AuthMethods
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive 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 @Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
val scope = rememberCoroutineScope() 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 phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") } var code by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) } var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) } var devCode by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var registerOpen by remember { mutableStateOf(false) }
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。 // 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
var serverOpen by remember { mutableStateOf(false) } 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) { LaunchedEffect(seconds) {
if (seconds > 0) { if (seconds > 0) {
delay(1000) 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() { fun send() {
scope.launch { scope.launch {
busy = true busy = true
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
error = null error = null
} }
.onFailure { .onFailure { report(it, "验证码没发出去") }
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
}
busy = false busy = false
} }
} }
fun submit() { fun submitCode() {
scope.launch { scope.launch {
busy = true busy = true
runCatching { api.login(phone.trim(), code.trim()) } runCatching { api.login(phone.trim(), code.trim()) }
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
error = null error = null
onLoggedIn() onLoggedIn()
} }
.onFailure { .onFailure { report(it, "登录没成功") }
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
}
busy = false busy = false
} }
} }
val smsAvailable = methods?.smsCode == true
val registerAvailable = methods?.emailRegister == true
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -160,12 +207,70 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
Text("登录", style = MaterialTheme.typography.headlineMedium) Text("登录", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。", if (smsAvailable) {
"手机号或邮箱 + 密码,也可以收验证码登录。"
} else {
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = InkMuted, color = InkMuted,
) )
Spacer(Modifier.height(22.dp)) // 两条路都可以走时才给切换 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(18.dp))
if (mode == "password") {
OutlinedTextField(
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 = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
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( OutlinedTextField(
value = phone, value = phone,
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) }, onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
@@ -218,6 +323,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
) )
} }
}
val notice = error val notice = error
if (notice != null) { if (notice != null) {
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
} }
Spacer(Modifier.height(22.dp)) Spacer(Modifier.height(22.dp))
if (mode == "password") {
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = accountReady(account) && password.isNotEmpty() && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submitPassword() },
)
} else {
PrimaryButton( PrimaryButton(
text = if (busy) "请稍候…" else "登录", text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy, enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { submit() }, 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)) Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
} }
Spacer(Modifier.height(40.dp)) 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( Text(
item.title, item.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
color = if (item.isDone) InkMuted else Ink, color = if (item.isDone) InkMuted else Ink,
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None, textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
) )
@@ -164,7 +164,7 @@ fun TicketsScreen(
Text( Text(
ticket.title, ticket.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row( Row(
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -96,25 +97,44 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
} }
} }
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */ /**
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
*
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
*/
@Composable @Composable
fun SoftCard( fun SoftCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
padding: Dp = 14.dp, padding: Dp = 14.dp,
accent: Color? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val shape = RoundedCornerShape(14.dp)
val base = modifier val base = modifier
.fillMaxWidth() .fillMaxWidth()
.border(1.dp, Line, RoundedCornerShape(14.dp)) .clip(shape)
.background(Color.White, RoundedCornerShape(14.dp)) .border(1.dp, Line, shape)
.background(Color.White, shape)
Surface( Surface(
modifier = if (onClick != null) base.clickable { onClick() } else base, modifier = if (onClick != null) base.clickable { onClick() } else base,
color = Color.White, color = Color.White,
shape = RoundedCornerShape(14.dp), shape = shape,
) { ) {
Column {
if (accent != null) {
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
}
Column(modifier = Modifier.padding(padding), content = content) 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 @Composable