Commit 52018e457d85e86756e7f379f1cadcc8343d36d1
1 parent
a61d5fc8
feat(lifelog/photolog): 联系人档案页改 tab 式 + 记录搜索
档案页从一长条下拉改成:固定资料头 + 搜索框 + 顶部 TabRow 切换。 - 固定头部:头像 + 名字 + 微信号/备注/手机号 + 声纹绑定(绑定/改绑)+ AI 印象 - 搜索框:在当前 tab 的记录里实时过滤(caption/转写/聊天/朋友圈文本子串) - TabRow:照片 / 语音 / 聊天 / 朋友圈(各带数量),切到哪个就显示哪个列表 - 照片:3 列网格,点开看大图 - 语音:TA 说过的录音段,点击播放 - 聊天:微信气泡;朋友圈:动态列表 实测:火星人档案页头部固定,「照片1/语音54/聊天1/朋友圈10」tab 切换正常。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
1 changed file
with
110 additions
and
128 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| 1 | 1 | package com.lemon.lifelog |
| 2 | 2 | |
| 3 | +import androidx.compose.foundation.background | |
| 3 | 4 | import androidx.compose.foundation.clickable |
| 4 | 5 | import androidx.compose.foundation.layout.Arrangement |
| 5 | 6 | import androidx.compose.foundation.layout.Box |
| ... | ... | @@ -10,26 +11,29 @@ import androidx.compose.foundation.layout.fillMaxSize |
| 10 | 11 | import androidx.compose.foundation.layout.fillMaxWidth |
| 11 | 12 | import androidx.compose.foundation.layout.height |
| 12 | 13 | import androidx.compose.foundation.layout.padding |
| 14 | +import androidx.compose.foundation.layout.aspectRatio | |
| 13 | 15 | import androidx.compose.foundation.layout.size |
| 14 | 16 | import androidx.compose.foundation.layout.width |
| 15 | 17 | import androidx.compose.foundation.layout.widthIn |
| 16 | 18 | import androidx.compose.foundation.lazy.LazyColumn |
| 17 | -import androidx.compose.foundation.lazy.LazyRow | |
| 18 | 19 | import androidx.compose.foundation.lazy.items |
| 20 | +import androidx.compose.foundation.lazy.grid.GridCells | |
| 21 | +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | |
| 22 | +import androidx.compose.foundation.lazy.grid.items as gridItems | |
| 19 | 23 | import androidx.compose.foundation.shape.CircleShape |
| 20 | 24 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 21 | 25 | import androidx.compose.material.icons.Icons |
| 22 | 26 | import androidx.compose.material.icons.automirrored.filled.ArrowBack |
| 23 | 27 | import androidx.compose.material3.AlertDialog |
| 24 | -import androidx.compose.material3.Card | |
| 25 | 28 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 26 | -import androidx.compose.material3.HorizontalDivider | |
| 27 | 29 | import androidx.compose.material3.Icon |
| 28 | 30 | import androidx.compose.material3.IconButton |
| 29 | 31 | import androidx.compose.material3.MaterialTheme |
| 30 | -import androidx.compose.material3.OutlinedButton | |
| 32 | +import androidx.compose.material3.OutlinedTextField | |
| 31 | 33 | import androidx.compose.material3.RadioButton |
| 32 | 34 | import androidx.compose.material3.Surface |
| 35 | +import androidx.compose.material3.Tab | |
| 36 | +import androidx.compose.material3.TabRow | |
| 33 | 37 | import androidx.compose.material3.Text |
| 34 | 38 | import androidx.compose.material3.TextButton |
| 35 | 39 | import androidx.compose.material3.TopAppBar |
| ... | ... | @@ -90,6 +94,14 @@ internal fun PersonProfileScreen( |
| 90 | 94 | var playingId by remember { mutableStateOf<Long?>(null) } |
| 91 | 95 | DisposableEffect(player) { onDispose { player.stop() } } |
| 92 | 96 | |
| 97 | + var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 | |
| 98 | + var query by remember { mutableStateOf("") } | |
| 99 | + val q = query.trim() | |
| 100 | + val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } | |
| 101 | + val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } | |
| 102 | + val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } | |
| 103 | + val fMoments = remember(moments, q) { if (q.isBlank()) moments else moments.filter { it.text.contains(q, true) } } | |
| 104 | + | |
| 93 | 105 | Column(modifier = Modifier.fillMaxSize()) { |
| 94 | 106 | TopAppBar( |
| 95 | 107 | title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) }, |
| ... | ... | @@ -97,120 +109,49 @@ internal fun PersonProfileScreen( |
| 97 | 109 | IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } |
| 98 | 110 | }, |
| 99 | 111 | ) |
| 100 | - LazyColumn(modifier = Modifier.fillMaxSize()) { | |
| 101 | - // ----- 资料头 ----- | |
| 102 | - item { | |
| 103 | - person?.let { p -> | |
| 104 | - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { | |
| 105 | - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { | |
| 106 | - if (p.avatarPath != null) { | |
| 107 | - AsyncImage( | |
| 108 | - model = File(p.avatarPath), | |
| 109 | - contentDescription = null, | |
| 110 | - contentScale = ContentScale.Crop, | |
| 111 | - modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)), | |
| 112 | - ) | |
| 113 | - } else { | |
| 114 | - Box(Modifier.size(72.dp).clip(RoundedCornerShape(12.dp))) {} | |
| 115 | - } | |
| 116 | - Column(modifier = Modifier.weight(1f)) { | |
| 117 | - Text(p.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 118 | - p.nickname?.takeIf { it != p.name }?.let { Text("微信名:$it", style = MaterialTheme.typography.bodySmall) } | |
| 119 | - } | |
| 120 | - } | |
| 121 | - Spacer(Modifier.height(10.dp)) | |
| 122 | - InfoRow("备注", p.remark) | |
| 123 | - InfoRow("微信号", p.alias) | |
| 124 | - InfoRow("手机号", p.phone) | |
| 125 | - // 声纹绑定 | |
| 126 | - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 127 | - val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name | |
| 128 | - Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) | |
| 129 | - OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } | |
| 130 | - } | |
| 131 | - // AI 印象 | |
| 132 | - Spacer(Modifier.height(6.dp)) | |
| 133 | - p.impression?.let { | |
| 134 | - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | |
| 135 | - Text("🧠 $it", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp)) | |
| 136 | - } | |
| 137 | - Spacer(Modifier.height(6.dp)) | |
| 138 | - } | |
| 139 | - OutlinedButton(onClick = { vm.enrichImpression(personId) }) { | |
| 140 | - Text(if (p.impression == null) "生成 AI 印象(本地 4B,看朋友圈)" else "重新生成印象") | |
| 141 | - } | |
| 142 | - } | |
| 143 | - HorizontalDivider() | |
| 144 | - } | |
| 145 | - } | |
| 146 | - // ----- 照片 ----- | |
| 147 | - item { | |
| 148 | - SectionHeader("照片", photos.size) | |
| 149 | - if (photos.isEmpty()) { | |
| 150 | - Text("还没有关联照片。导入照片后会自动认出 TA。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) | |
| 151 | - } else { | |
| 152 | - LazyRow( | |
| 153 | - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), | |
| 154 | - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp), | |
| 155 | - horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 156 | - ) { | |
| 157 | - items(photos, key = { it.id }) { ph -> | |
| 158 | - AsyncImage( | |
| 159 | - model = File(ph.path), | |
| 160 | - contentDescription = ph.caption, | |
| 161 | - contentScale = ContentScale.Crop, | |
| 162 | - modifier = Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).clickable { onOpenPhoto(ph.id) }, | |
| 163 | - ) | |
| 164 | - } | |
| 112 | + person?.let { p -> ProfileHeader(p, speakers, onBind = { bindVoice = true }, onEnrich = { vm.enrichImpression(personId) }) } | |
| 113 | + OutlinedTextField( | |
| 114 | + value = query, onValueChange = { query = it }, singleLine = true, | |
| 115 | + placeholder = { Text("在 TA 的记录里搜索…") }, | |
| 116 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | |
| 117 | + ) | |
| 118 | + TabRow(selectedTabIndex = tab) { | |
| 119 | + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) | |
| 120 | + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) | |
| 121 | + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) | |
| 122 | + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) | |
| 123 | + } | |
| 124 | + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { | |
| 125 | + when (tab) { | |
| 126 | + 0 -> if (fPhotos.isEmpty()) EmptyHint(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") | |
| 127 | + else LazyVerticalGrid( | |
| 128 | + columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(4.dp), | |
| 129 | + verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 130 | + ) { | |
| 131 | + gridItems(fPhotos, key = { it.id }) { ph -> | |
| 132 | + AsyncImage( | |
| 133 | + model = File(ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, | |
| 134 | + modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, | |
| 135 | + ) | |
| 165 | 136 | } |
| 166 | 137 | } |
| 167 | - HorizontalDivider() | |
| 168 | - } | |
| 169 | - // ----- 录音 ----- | |
| 170 | - item { SectionHeader("录音", audio.size) } | |
| 171 | - if (sid == null) { | |
| 172 | - item { | |
| 173 | - Text("绑定声纹后,这里会列出 TA 说过的录音。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) | |
| 174 | - } | |
| 175 | - } else if (audio.isEmpty()) { | |
| 176 | - item { Text("这个声纹名下还没有录音段。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 177 | - } else { | |
| 178 | - items(audio, key = { it.id }) { row -> | |
| 179 | - Row( | |
| 180 | - modifier = Modifier.fillMaxWidth().clickable { | |
| 138 | + 1 -> if (sid == null) EmptyHint("还没绑定声纹。点上方「绑定」选一个说话人,这里就会列出 TA 说过的录音。") | |
| 139 | + else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") | |
| 140 | + else LazyColumn(Modifier.fillMaxSize()) { | |
| 141 | + items(fAudio, key = { it.id }) { row -> | |
| 142 | + AudioRow(row, playing = playingId == row.id) { | |
| 181 | 143 | if (playingId == row.id) { player.stop(); playingId = null } |
| 182 | 144 | else { playingId = row.id; player.play(row.audioPath) { playingId = null } } |
| 183 | - }.padding(horizontal = 16.dp, vertical = 8.dp), | |
| 184 | - horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 185 | - verticalAlignment = Alignment.CenterVertically, | |
| 186 | - ) { | |
| 187 | - Text(if (playingId == row.id) "■" else "▶", style = MaterialTheme.typography.titleMedium) | |
| 188 | - Column(modifier = Modifier.weight(1f)) { | |
| 189 | - Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) | |
| 190 | - Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) | |
| 191 | 145 | } |
| 192 | 146 | } |
| 193 | 147 | } |
| 148 | + 2 -> if (wxid == null) EmptyHint("非微信联系人。") | |
| 149 | + else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") | |
| 150 | + else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } | |
| 151 | + else -> if (wxid == null) EmptyHint("非微信联系人。") | |
| 152 | + else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") | |
| 153 | + else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } | |
| 194 | 154 | } |
| 195 | - // ----- 微信聊天 ----- | |
| 196 | - item { HorizontalDivider(); SectionHeader("微信聊天", messages.size) } | |
| 197 | - if (wxid == null) { | |
| 198 | - item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 199 | - } else if (messages.isEmpty()) { | |
| 200 | - item { Text("这台设备上没有跟 TA 的聊天记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 201 | - } else { | |
| 202 | - items(messages, key = { it.id }) { m -> ChatBubble(m) } | |
| 203 | - } | |
| 204 | - // ----- 朋友圈 ----- | |
| 205 | - item { HorizontalDivider(); SectionHeader("朋友圈", moments.size) } | |
| 206 | - if (wxid == null) { | |
| 207 | - item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 208 | - } else if (moments.isEmpty()) { | |
| 209 | - item { Text("没有 TA 的朋友圈记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 210 | - } else { | |
| 211 | - items(moments, key = { it.id }) { mo -> MomentRow(mo) } | |
| 212 | - } | |
| 213 | - item { Spacer(Modifier.height(24.dp)) } | |
| 214 | 155 | } |
| 215 | 156 | } |
| 216 | 157 | |
| ... | ... | @@ -225,6 +166,65 @@ internal fun PersonProfileScreen( |
| 225 | 166 | } |
| 226 | 167 | } |
| 227 | 168 | |
| 169 | +/** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ | |
| 170 | +@Composable | |
| 171 | +private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () -> Unit, onEnrich: () -> Unit) { | |
| 172 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | |
| 173 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 174 | + if (p.avatarPath != null) { | |
| 175 | + AsyncImage( | |
| 176 | + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, | |
| 177 | + modifier = Modifier.size(60.dp).clip(RoundedCornerShape(10.dp)), | |
| 178 | + ) | |
| 179 | + } else { | |
| 180 | + Box(Modifier.size(60.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant)) | |
| 181 | + } | |
| 182 | + Column(modifier = Modifier.weight(1f)) { | |
| 183 | + Text(p.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 184 | + val sub = listOfNotNull(p.alias?.let { "微信号 $it" }, p.remark?.let { "备注 $it" }, p.phone?.let { "📱$it" }).joinToString(" ") | |
| 185 | + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 186 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 187 | + val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name | |
| 188 | + Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) | |
| 189 | + TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { | |
| 190 | + Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + p.impression?.let { | |
| 196 | + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | |
| 197 | + Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) | |
| 198 | + } | |
| 199 | + } | |
| 200 | + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | |
| 201 | + Text(if (p.impression == null) "🧠 生成 AI 印象(本地 4B 看朋友圈)" else "重新生成印象", style = MaterialTheme.typography.labelSmall) | |
| 202 | + } | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +@Composable | |
| 207 | +private fun EmptyHint(text: String) { | |
| 208 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 209 | + Text(text, style = MaterialTheme.typography.bodyMedium) | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +@Composable | |
| 214 | +private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) { | |
| 215 | + Row( | |
| 216 | + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 8.dp), | |
| 217 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 218 | + verticalAlignment = Alignment.CenterVertically, | |
| 219 | + ) { | |
| 220 | + Text(if (playing) "■" else "▶", style = MaterialTheme.typography.titleMedium) | |
| 221 | + Column(modifier = Modifier.weight(1f)) { | |
| 222 | + Text(PROFILE_FMT.format(Date(row.startTs)) + " · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.labelSmall) | |
| 223 | + Text(row.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) | |
| 224 | + } | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | 228 | @Composable |
| 229 | 229 | private fun MomentRow(m: Moment) { |
| 230 | 230 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| ... | ... | @@ -250,24 +250,6 @@ private fun ChatBubble(m: WechatMessage) { |
| 250 | 250 | } |
| 251 | 251 | |
| 252 | 252 | @Composable |
| 253 | -private fun InfoRow(label: String, value: String?) { | |
| 254 | - if (value.isNullOrBlank()) return | |
| 255 | - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 256 | - Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(56.dp)) | |
| 257 | - Text(value, style = MaterialTheme.typography.bodyMedium) | |
| 258 | - } | |
| 259 | -} | |
| 260 | - | |
| 261 | -@Composable | |
| 262 | -private fun SectionHeader(title: String, count: Int) { | |
| 263 | - Text( | |
| 264 | - "$title($count)", | |
| 265 | - style = MaterialTheme.typography.titleSmall, | |
| 266 | - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), | |
| 267 | - ) | |
| 268 | -} | |
| 269 | - | |
| 270 | -@Composable | |
| 271 | 253 | private fun BindVoiceDialog( |
| 272 | 254 | speakers: List<SpeakerProfile>, |
| 273 | 255 | current: Long?, | ... | ... |