Commit 1b30dac4ec61e8876fd943031e91ae5cee2b8086

Authored by yibo
1 parent 45e3e509

feat(lifelog/photolog): 人物档案改 LinkedIn 风(banner+大头像+关于卡)

参考 LinkedIn 档案格式重做档案页顶部:
- 渐变 banner + 压在上面的大圆头像(无头像显示名字首字)
- 名字 + 身份副行(微信名/真名/微信号/电话)
- 身份 chip:来源(微信/手机) · 🎤声纹 · 📷人脸(已具备则高亮)
- 操作行可横滚:编辑/录一段/选声纹/合并/AI印象
- 「关于」卡:简历 + AI 印象
下方保留 照片/语音/聊天/朋友圈 4 tab + 搜索不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
... ... @@ -2,7 +2,10 @@ package com.lemon.lifelog
2 2  
3 3 import androidx.compose.foundation.background
4 4 import androidx.compose.foundation.clickable
  5 +import androidx.compose.foundation.horizontalScroll
  6 +import androidx.compose.foundation.rememberScrollState
5 7 import androidx.compose.foundation.layout.Arrangement
  8 +import androidx.compose.foundation.layout.offset
6 9 import androidx.compose.foundation.layout.Box
7 10 import androidx.compose.foundation.layout.Column
8 11 import androidx.compose.foundation.layout.Row
... ... @@ -49,6 +52,7 @@ import androidx.compose.runtime.setValue
49 52 import androidx.compose.ui.Alignment
50 53 import androidx.compose.ui.Modifier
51 54 import androidx.compose.ui.draw.clip
  55 +import androidx.compose.ui.graphics.Brush
52 56 import androidx.compose.ui.layout.ContentScale
53 57 import androidx.compose.ui.text.style.TextOverflow
54 58 import androidx.compose.ui.unit.dp
... ... @@ -115,7 +119,7 @@ internal fun PersonProfileScreen(
115 119 },
116 120 )
117 121 person?.let { p ->
118   - ProfileHeader(
  122 + ProfileHero(
119 123 p, speakers,
120 124 onBind = { bindVoice = true },
121 125 onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true },
... ... @@ -247,9 +251,12 @@ private fun EnrollVoiceDialog(
247 251 )
248 252 }
249 253  
250   -/** 固定在 tab 上方的资料头:头像 + 名字 + 微信名/真名/微信号/电话 + 简历 + 声纹绑定 + AI 印象。 */
  254 +/**
  255 + * LinkedIn 风资料头:banner + 压在上面的大圆头像 + 名字 + 身份副行 + 来源/声纹/人脸 chip
  256 + * + 操作行 + 「关于」卡(简历 + AI 印象)。固定在 tab 上方。
  257 + */
251 258 @Composable
252   -private fun ProfileHeader(
  259 +private fun ProfileHero(
253 260 p: Person,
254 261 speakers: List<SpeakerProfile>,
255 262 onBind: () -> Unit,
... ... @@ -258,61 +265,129 @@ private fun ProfileHeader(
258 265 onEditProfile: () -> Unit,
259 266 onMerge: () -> Unit,
260 267 ) {
261   - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
262   - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
263   - if (p.avatarPath != null) {
264   - AsyncImage(
265   - model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop,
266   - modifier = Modifier.size(60.dp).clip(RoundedCornerShape(10.dp)),
267   - )
268   - } else {
269   - Box(Modifier.size(60.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant))
270   - }
271   - Column(modifier = Modifier.weight(1f)) {
272   - Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
273   - val sub = listOfNotNull(
274   - p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" },
275   - p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" },
276   - p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" },
277   - p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" },
278   - ).joinToString(" ")
279   - if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
280   - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
281   - val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name
282   - Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium)
283   - TextButton(onClick = onRecordVoice, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
284   - Text("🎤 录一段")
285   - }
286   - TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
287   - Text(if (p.voiceSpeakerId == null) "选已有" else "改绑")
288   - }
  268 + val avatarSize = 84.dp
  269 + Column(modifier = Modifier.fillMaxWidth()) {
  270 + // ---- banner + 头像(头像下沿压出 banner)----
  271 + Box(modifier = Modifier.fillMaxWidth()) {
  272 + Box(
  273 + modifier = Modifier.fillMaxWidth().height(92.dp).background(
  274 + Brush.linearGradient(
  275 + listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary),
  276 + ),
  277 + ),
  278 + )
  279 + // 白边圆头像,向下偏移压在 banner 边缘
  280 + Box(
  281 + modifier = Modifier.padding(start = 16.dp).offset(y = 52.dp)
  282 + .size(avatarSize + 6.dp).clip(CircleShape)
  283 + .background(MaterialTheme.colorScheme.surface),
  284 + contentAlignment = Alignment.Center,
  285 + ) {
  286 + if (p.avatarPath != null) {
  287 + AsyncImage(
  288 + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop,
  289 + modifier = Modifier.size(avatarSize).clip(CircleShape),
  290 + )
  291 + } else {
  292 + Box(
  293 + Modifier.size(avatarSize).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant),
  294 + contentAlignment = Alignment.Center,
  295 + ) { Text(p.name.take(1), style = MaterialTheme.typography.headlineMedium) }
289 296 }
290 297 }
291 298 }
292   - p.resume?.takeIf { it.isNotBlank() }?.let {
293   - Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
294   - Text("📄 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp))
295   - }
296   - }
297   - p.impression?.let {
298   - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
299   - Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp))
  299 + Spacer(Modifier.height(54.dp)) // 给头像下沿留位(offset 52 + 90 高 - banner 92 = 50,多留一点)
  300 +
  301 + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
  302 + Text(p.name, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
  303 + // 身份副行(headline):微信名 / 真名 / 微信号 / 电话
  304 + val sub = listOfNotNull(
  305 + p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" },
  306 + p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" },
  307 + p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" },
  308 + p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" },
  309 + ).joinToString(" · ")
  310 + if (sub.isNotBlank()) {
  311 + Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
300 312 }
301   - }
302   - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
303   - TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) {
304   - Text("✎ 编辑资料", style = MaterialTheme.typography.labelSmall)
  313 + // 身份 chip:来源 + 声纹 + 人脸
  314 + val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name
  315 + Spacer(Modifier.height(6.dp))
  316 + Row(
  317 + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
  318 + horizontalArrangement = Arrangement.spacedBy(6.dp),
  319 + verticalAlignment = Alignment.CenterVertically,
  320 + ) {
  321 + when {
  322 + p.contactRef?.startsWith("wechat:") == true -> Pill("💬 微信")
  323 + p.contactRef?.startsWith("phone:") == true -> Pill("📱 手机")
  324 + else -> {}
  325 + }
  326 + Pill(if (sName != null) "🎤 声纹 $sName" else "🎤 声纹未绑", highlight = sName != null)
  327 + if (p.faceEmbedding != null) Pill("📷 人脸 ✓", highlight = true)
305 328 }
306   - TextButton(onClick = onMerge, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) {
307   - Text("🔗 合并重复", style = MaterialTheme.typography.labelSmall)
  329 +
  330 + // ---- 操作行(可横向滚动)----
  331 + Spacer(Modifier.height(4.dp))
  332 + Row(
  333 + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
  334 + horizontalArrangement = Arrangement.spacedBy(4.dp),
  335 + verticalAlignment = Alignment.CenterVertically,
  336 + ) {
  337 + val pad = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)
  338 + TextButton(onClick = onEditProfile, contentPadding = pad) { Text("✎ 编辑", style = MaterialTheme.typography.labelMedium) }
  339 + TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) }
  340 + TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) }
  341 + TextButton(onClick = onMerge, contentPadding = pad) { Text("🔗 合并", style = MaterialTheme.typography.labelMedium) }
  342 + TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) }
308 343 }
309   - TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) {
310   - Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall)
  344 +
  345 + // ---- 「关于」卡:简历 + AI 印象 ----
  346 + Spacer(Modifier.height(6.dp))
  347 + Surface(
  348 + color = MaterialTheme.colorScheme.surfaceVariant,
  349 + shape = RoundedCornerShape(12.dp),
  350 + modifier = Modifier.fillMaxWidth(),
  351 + ) {
  352 + Column(modifier = Modifier.padding(12.dp)) {
  353 + Text("关于", style = MaterialTheme.typography.titleSmall)
  354 + Spacer(Modifier.height(4.dp))
  355 + val resume = p.resume?.takeIf { it.isNotBlank() }
  356 + if (resume != null) {
  357 + Text(resume, style = MaterialTheme.typography.bodyMedium, maxLines = 6, overflow = TextOverflow.Ellipsis)
  358 + } else {
  359 + Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
  360 + }
  361 + p.impression?.takeIf { it.isNotBlank() }?.let {
  362 + Spacer(Modifier.height(8.dp))
  363 + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth()) {
  364 + Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp))
  365 + }
  366 + }
  367 + }
311 368 }
  369 + Spacer(Modifier.height(6.dp))
312 370 }
313 371 }
314 372 }
315 373  
  374 +/** 小药丸标签(来源 / 声纹 / 人脸)。highlight=已具备时高亮。 */
  375 +@Composable
  376 +private fun Pill(text: String, highlight: Boolean = false) {
  377 + Surface(
  378 + color = if (highlight) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
  379 + shape = RoundedCornerShape(50),
  380 + ) {
  381 + Text(
  382 + text,
  383 + style = MaterialTheme.typography.labelSmall,
  384 + color = if (highlight) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
  385 + modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
  386 + maxLines = 1,
  387 + )
  388 + }
  389 +}
  390 +
316 391 /** 编辑真名 + 简历(手填,本地保存)。 */
317 392 @Composable
318 393 private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
... ...