From 1b30dac4ec61e8876fd943031e91ae5cee2b8086 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 8 Jun 2026 20:30:30 +0800 Subject: [PATCH] feat(lifelog/photolog): 人物档案改 LinkedIn 风(banner+大头像+关于卡) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------- 1 file changed, 122 insertions(+), 47 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index 600f63e..ec4e4cf 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -2,7 +2,10 @@ package com.lemon.lifelog import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -49,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -115,7 +119,7 @@ internal fun PersonProfileScreen( }, ) person?.let { p -> - ProfileHeader( + ProfileHero( p, speakers, onBind = { bindVoice = true }, onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, @@ -247,9 +251,12 @@ private fun EnrollVoiceDialog( ) } -/** 固定在 tab 上方的资料头:头像 + 名字 + 微信名/真名/微信号/电话 + 简历 + 声纹绑定 + AI 印象。 */ +/** + * LinkedIn 风资料头:banner + 压在上面的大圆头像 + 名字 + 身份副行 + 来源/声纹/人脸 chip + * + 操作行 + 「关于」卡(简历 + AI 印象)。固定在 tab 上方。 + */ @Composable -private fun ProfileHeader( +private fun ProfileHero( p: Person, speakers: List, onBind: () -> Unit, @@ -258,61 +265,129 @@ private fun ProfileHeader( onEditProfile: () -> Unit, onMerge: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (p.avatarPath != null) { - AsyncImage( - model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.size(60.dp).clip(RoundedCornerShape(10.dp)), - ) - } else { - Box(Modifier.size(60.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant)) - } - Column(modifier = Modifier.weight(1f)) { - Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - val sub = listOfNotNull( - p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" }, - p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" }, - p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" }, - p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" }, - ).joinToString(" ") - if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name - Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) - TextButton(onClick = onRecordVoice, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { - Text("🎤 录一段") - } - TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { - Text(if (p.voiceSpeakerId == null) "选已有" else "改绑") - } + val avatarSize = 84.dp + Column(modifier = Modifier.fillMaxWidth()) { + // ---- banner + 头像(头像下沿压出 banner)---- + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier.fillMaxWidth().height(92.dp).background( + Brush.linearGradient( + listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary), + ), + ), + ) + // 白边圆头像,向下偏移压在 banner 边缘 + Box( + modifier = Modifier.padding(start = 16.dp).offset(y = 52.dp) + .size(avatarSize + 6.dp).clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + if (p.avatarPath != null) { + AsyncImage( + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.size(avatarSize).clip(CircleShape), + ) + } else { + Box( + Modifier.size(avatarSize).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { Text(p.name.take(1), style = MaterialTheme.typography.headlineMedium) } } } } - p.resume?.takeIf { it.isNotBlank() }?.let { - Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { - Text("📄 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) - } - } - p.impression?.let { - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { - Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) + Spacer(Modifier.height(54.dp)) // 给头像下沿留位(offset 52 + 90 高 - banner 92 = 50,多留一点) + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text(p.name, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + // 身份副行(headline):微信名 / 真名 / 微信号 / 电话 + val sub = listOfNotNull( + p.nickname?.takeIf { it.isNotBlank() && it != p.name }?.let { "微信名 $it" }, + p.realName?.takeIf { it.isNotBlank() }?.let { "真名 $it" }, + p.alias?.takeIf { it.isNotBlank() }?.let { "微信号 $it" }, + p.phone?.takeIf { it.isNotBlank() }?.let { "📱$it" }, + ).joinToString(" · ") + if (sub.isNotBlank()) { + Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) } - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { - Text("✎ 编辑资料", style = MaterialTheme.typography.labelSmall) + // 身份 chip:来源 + 声纹 + 人脸 + val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when { + p.contactRef?.startsWith("wechat:") == true -> Pill("💬 微信") + p.contactRef?.startsWith("phone:") == true -> Pill("📱 手机") + else -> {} + } + Pill(if (sName != null) "🎤 声纹 $sName" else "🎤 声纹未绑", highlight = sName != null) + if (p.faceEmbedding != null) Pill("📷 人脸 ✓", highlight = true) } - TextButton(onClick = onMerge, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { - Text("🔗 合并重复", style = MaterialTheme.typography.labelSmall) + + // ---- 操作行(可横向滚动)---- + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val pad = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp) + TextButton(onClick = onEditProfile, contentPadding = pad) { Text("✎ 编辑", style = MaterialTheme.typography.labelMedium) } + TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) } + TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) } + TextButton(onClick = onMerge, contentPadding = pad) { Text("🔗 合并", style = MaterialTheme.typography.labelMedium) } + TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } } - TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { - Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) + + // ---- 「关于」卡:简历 + AI 印象 ---- + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text("关于", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + val resume = p.resume?.takeIf { it.isNotBlank() } + if (resume != null) { + Text(resume, style = MaterialTheme.typography.bodyMedium, maxLines = 6, overflow = TextOverflow.Ellipsis) + } else { + Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + p.impression?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(8.dp)) + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth()) { + Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) + } + } + } } + Spacer(Modifier.height(6.dp)) } } } +/** 小药丸标签(来源 / 声纹 / 人脸)。highlight=已具备时高亮。 */ +@Composable +private fun Pill(text: String, highlight: Boolean = false) { + Surface( + color = if (highlight) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50), + ) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = if (highlight) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp), + maxLines = 1, + ) + } +} + /** 编辑真名 + 简历(手填,本地保存)。 */ @Composable private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { -- libgit2 0.21.4