From 52018e457d85e86756e7f379f1cadcc8343d36d1 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 7 Jun 2026 18:57:41 +0800 Subject: [PATCH] feat(lifelog/photolog): 联系人档案页改 tab 式 + 记录搜索 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------- 1 file changed, 110 insertions(+), 128 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 9e7abf6..779fe91 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 @@ -1,5 +1,6 @@ package com.lemon.lifelog +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,26 +11,29 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -90,6 +94,14 @@ internal fun PersonProfileScreen( var playingId by remember { mutableStateOf(null) } DisposableEffect(player) { onDispose { player.stop() } } + var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 + var query by remember { mutableStateOf("") } + val q = query.trim() + val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } + val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } + val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } + val fMoments = remember(moments, q) { if (q.isBlank()) moments else moments.filter { it.text.contains(q, true) } } + Column(modifier = Modifier.fillMaxSize()) { TopAppBar( title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) }, @@ -97,120 +109,49 @@ internal fun PersonProfileScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } }, ) - LazyColumn(modifier = Modifier.fillMaxSize()) { - // ----- 资料头 ----- - item { - person?.let { p -> - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { - if (p.avatarPath != null) { - AsyncImage( - model = File(p.avatarPath), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)), - ) - } else { - Box(Modifier.size(72.dp).clip(RoundedCornerShape(12.dp))) {} - } - Column(modifier = Modifier.weight(1f)) { - Text(p.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - p.nickname?.takeIf { it != p.name }?.let { Text("微信名:$it", style = MaterialTheme.typography.bodySmall) } - } - } - Spacer(Modifier.height(10.dp)) - InfoRow("备注", p.remark) - InfoRow("微信号", p.alias) - InfoRow("手机号", p.phone) - // 声纹绑定 - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name - Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) - OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } - } - // AI 印象 - Spacer(Modifier.height(6.dp)) - p.impression?.let { - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { - Text("🧠 $it", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp)) - } - Spacer(Modifier.height(6.dp)) - } - OutlinedButton(onClick = { vm.enrichImpression(personId) }) { - Text(if (p.impression == null) "生成 AI 印象(本地 4B,看朋友圈)" else "重新生成印象") - } - } - HorizontalDivider() - } - } - // ----- 照片 ----- - item { - SectionHeader("照片", photos.size) - if (photos.isEmpty()) { - Text("还没有关联照片。导入照片后会自动认出 TA。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) - } else { - LazyRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(photos, key = { it.id }) { ph -> - AsyncImage( - model = File(ph.path), - contentDescription = ph.caption, - contentScale = ContentScale.Crop, - modifier = Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).clickable { onOpenPhoto(ph.id) }, - ) - } + person?.let { p -> ProfileHeader(p, speakers, onBind = { bindVoice = true }, onEnrich = { vm.enrichImpression(personId) }) } + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("在 TA 的记录里搜索…") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (tab) { + 0 -> if (fPhotos.isEmpty()) EmptyHint(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") + else LazyVerticalGrid( + columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + gridItems(fPhotos, key = { it.id }) { ph -> + AsyncImage( + model = File(ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, + ) } } - HorizontalDivider() - } - // ----- 录音 ----- - item { SectionHeader("录音", audio.size) } - if (sid == null) { - item { - Text("绑定声纹后,这里会列出 TA 说过的录音。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) - } - } else if (audio.isEmpty()) { - item { Text("这个声纹名下还没有录音段。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - } else { - items(audio, key = { it.id }) { row -> - Row( - modifier = Modifier.fillMaxWidth().clickable { + 1 -> if (sid == null) EmptyHint("还没绑定声纹。点上方「绑定」选一个说话人,这里就会列出 TA 说过的录音。") + else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") + else LazyColumn(Modifier.fillMaxSize()) { + items(fAudio, key = { it.id }) { row -> + AudioRow(row, playing = playingId == row.id) { if (playingId == row.id) { player.stop(); playingId = null } else { playingId = row.id; player.play(row.audioPath) { playingId = null } } - }.padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(if (playingId == row.id) "■" else "▶", style = MaterialTheme.typography.titleMedium) - Column(modifier = Modifier.weight(1f)) { - Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) - Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) } } } + 2 -> if (wxid == null) EmptyHint("非微信联系人。") + else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") + else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } + else -> if (wxid == null) EmptyHint("非微信联系人。") + else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") + else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } } - // ----- 微信聊天 ----- - item { HorizontalDivider(); SectionHeader("微信聊天", messages.size) } - if (wxid == null) { - item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - } else if (messages.isEmpty()) { - item { Text("这台设备上没有跟 TA 的聊天记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - } else { - items(messages, key = { it.id }) { m -> ChatBubble(m) } - } - // ----- 朋友圈 ----- - item { HorizontalDivider(); SectionHeader("朋友圈", moments.size) } - if (wxid == null) { - item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - } else if (moments.isEmpty()) { - item { Text("没有 TA 的朋友圈记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - } else { - items(moments, key = { it.id }) { mo -> MomentRow(mo) } - } - item { Spacer(Modifier.height(24.dp)) } } } @@ -225,6 +166,65 @@ internal fun PersonProfileScreen( } } +/** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ +@Composable +private fun ProfileHeader(p: Person, speakers: List, onBind: () -> Unit, onEnrich: () -> 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.alias?.let { "微信号 $it" }, p.remark?.let { "备注 $it" }, p.phone?.let { "📱$it" }).joinToString(" ") + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 1, 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 = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { + Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") + } + } + } + } + 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)) + } + } + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { + Text(if (p.impression == null) "🧠 生成 AI 印象(本地 4B 看朋友圈)" else "重新生成印象", style = MaterialTheme.typography.labelSmall) + } + } +} + +@Composable +private fun EmptyHint(text: String) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (playing) "■" else "▶", style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.weight(1f)) { + Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) + Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + @Composable private fun MomentRow(m: Moment) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { @@ -250,24 +250,6 @@ private fun ChatBubble(m: WechatMessage) { } @Composable -private fun InfoRow(label: String, value: String?) { - if (value.isNullOrBlank()) return - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(56.dp)) - Text(value, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun SectionHeader(title: String, count: Int) { - Text( - "$title($count)", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) -} - -@Composable private fun BindVoiceDialog( speakers: List, current: Long?, -- libgit2 0.21.4