Commit 52018e457d85e86756e7f379f1cadcc8343d36d1

Authored by yibo
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>
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?,
... ...