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,7 +2,10 @@ package com.lemon.lifelog
2 2
3 import androidx.compose.foundation.background 3 import androidx.compose.foundation.background
4 import androidx.compose.foundation.clickable 4 import androidx.compose.foundation.clickable
  5 +import androidx.compose.foundation.horizontalScroll
  6 +import androidx.compose.foundation.rememberScrollState
5 import androidx.compose.foundation.layout.Arrangement 7 import androidx.compose.foundation.layout.Arrangement
  8 +import androidx.compose.foundation.layout.offset
6 import androidx.compose.foundation.layout.Box 9 import androidx.compose.foundation.layout.Box
7 import androidx.compose.foundation.layout.Column 10 import androidx.compose.foundation.layout.Column
8 import androidx.compose.foundation.layout.Row 11 import androidx.compose.foundation.layout.Row
@@ -49,6 +52,7 @@ import androidx.compose.runtime.setValue @@ -49,6 +52,7 @@ import androidx.compose.runtime.setValue
49 import androidx.compose.ui.Alignment 52 import androidx.compose.ui.Alignment
50 import androidx.compose.ui.Modifier 53 import androidx.compose.ui.Modifier
51 import androidx.compose.ui.draw.clip 54 import androidx.compose.ui.draw.clip
  55 +import androidx.compose.ui.graphics.Brush
52 import androidx.compose.ui.layout.ContentScale 56 import androidx.compose.ui.layout.ContentScale
53 import androidx.compose.ui.text.style.TextOverflow 57 import androidx.compose.ui.text.style.TextOverflow
54 import androidx.compose.ui.unit.dp 58 import androidx.compose.ui.unit.dp
@@ -115,7 +119,7 @@ internal fun PersonProfileScreen( @@ -115,7 +119,7 @@ internal fun PersonProfileScreen(
115 }, 119 },
116 ) 120 )
117 person?.let { p -> 121 person?.let { p ->
118 - ProfileHeader( 122 + ProfileHero(
119 p, speakers, 123 p, speakers,
120 onBind = { bindVoice = true }, 124 onBind = { bindVoice = true },
121 onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, 125 onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true },
@@ -247,9 +251,12 @@ private fun EnrollVoiceDialog( @@ -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 @Composable 258 @Composable
252 -private fun ProfileHeader( 259 +private fun ProfileHero(
253 p: Person, 260 p: Person,
254 speakers: List<SpeakerProfile>, 261 speakers: List<SpeakerProfile>,
255 onBind: () -> Unit, 262 onBind: () -> Unit,
@@ -258,61 +265,129 @@ private fun ProfileHeader( @@ -258,61 +265,129 @@ private fun ProfileHeader(
258 onEditProfile: () -> Unit, 265 onEditProfile: () -> Unit,
259 onMerge: () -> Unit, 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 @Composable 392 @Composable
318 private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { 393 private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {