Commit 1b30dac4ec61e8876fd943031e91ae5cee2b8086
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>
Showing
1 changed file
with
122 additions
and
47 deletions
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) { | ... | ... |