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,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) { |