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 package com.lemon.lifelog 1 package com.lemon.lifelog
2 2
  3 +import androidx.compose.foundation.background
3 import androidx.compose.foundation.clickable 4 import androidx.compose.foundation.clickable
4 import androidx.compose.foundation.layout.Arrangement 5 import androidx.compose.foundation.layout.Arrangement
5 import androidx.compose.foundation.layout.Box 6 import androidx.compose.foundation.layout.Box
@@ -10,26 +11,29 @@ import androidx.compose.foundation.layout.fillMaxSize @@ -10,26 +11,29 @@ import androidx.compose.foundation.layout.fillMaxSize
10 import androidx.compose.foundation.layout.fillMaxWidth 11 import androidx.compose.foundation.layout.fillMaxWidth
11 import androidx.compose.foundation.layout.height 12 import androidx.compose.foundation.layout.height
12 import androidx.compose.foundation.layout.padding 13 import androidx.compose.foundation.layout.padding
  14 +import androidx.compose.foundation.layout.aspectRatio
13 import androidx.compose.foundation.layout.size 15 import androidx.compose.foundation.layout.size
14 import androidx.compose.foundation.layout.width 16 import androidx.compose.foundation.layout.width
15 import androidx.compose.foundation.layout.widthIn 17 import androidx.compose.foundation.layout.widthIn
16 import androidx.compose.foundation.lazy.LazyColumn 18 import androidx.compose.foundation.lazy.LazyColumn
17 -import androidx.compose.foundation.lazy.LazyRow  
18 import androidx.compose.foundation.lazy.items 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 import androidx.compose.foundation.shape.CircleShape 23 import androidx.compose.foundation.shape.CircleShape
20 import androidx.compose.foundation.shape.RoundedCornerShape 24 import androidx.compose.foundation.shape.RoundedCornerShape
21 import androidx.compose.material.icons.Icons 25 import androidx.compose.material.icons.Icons
22 import androidx.compose.material.icons.automirrored.filled.ArrowBack 26 import androidx.compose.material.icons.automirrored.filled.ArrowBack
23 import androidx.compose.material3.AlertDialog 27 import androidx.compose.material3.AlertDialog
24 -import androidx.compose.material3.Card  
25 import androidx.compose.material3.ExperimentalMaterial3Api 28 import androidx.compose.material3.ExperimentalMaterial3Api
26 -import androidx.compose.material3.HorizontalDivider  
27 import androidx.compose.material3.Icon 29 import androidx.compose.material3.Icon
28 import androidx.compose.material3.IconButton 30 import androidx.compose.material3.IconButton
29 import androidx.compose.material3.MaterialTheme 31 import androidx.compose.material3.MaterialTheme
30 -import androidx.compose.material3.OutlinedButton 32 +import androidx.compose.material3.OutlinedTextField
31 import androidx.compose.material3.RadioButton 33 import androidx.compose.material3.RadioButton
32 import androidx.compose.material3.Surface 34 import androidx.compose.material3.Surface
  35 +import androidx.compose.material3.Tab
  36 +import androidx.compose.material3.TabRow
33 import androidx.compose.material3.Text 37 import androidx.compose.material3.Text
34 import androidx.compose.material3.TextButton 38 import androidx.compose.material3.TextButton
35 import androidx.compose.material3.TopAppBar 39 import androidx.compose.material3.TopAppBar
@@ -90,6 +94,14 @@ internal fun PersonProfileScreen( @@ -90,6 +94,14 @@ internal fun PersonProfileScreen(
90 var playingId by remember { mutableStateOf<Long?>(null) } 94 var playingId by remember { mutableStateOf<Long?>(null) }
91 DisposableEffect(player) { onDispose { player.stop() } } 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 Column(modifier = Modifier.fillMaxSize()) { 105 Column(modifier = Modifier.fillMaxSize()) {
94 TopAppBar( 106 TopAppBar(
95 title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) }, 107 title = { Text(person?.name ?: "联系人", maxLines = 1, overflow = TextOverflow.Ellipsis) },
@@ -97,120 +109,49 @@ internal fun PersonProfileScreen( @@ -97,120 +109,49 @@ internal fun PersonProfileScreen(
97 IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } 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 if (playingId == row.id) { player.stop(); playingId = null } 143 if (playingId == row.id) { player.stop(); playingId = null }
182 else { playingId = row.id; player.play(row.audioPath) { playingId = null } } 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,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 @Composable 228 @Composable
229 private fun MomentRow(m: Moment) { 229 private fun MomentRow(m: Moment) {
230 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { 230 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
@@ -250,24 +250,6 @@ private fun ChatBubble(m: WechatMessage) { @@ -250,24 +250,6 @@ private fun ChatBubble(m: WechatMessage) {
250 } 250 }
251 251
252 @Composable 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 private fun BindVoiceDialog( 253 private fun BindVoiceDialog(
272 speakers: List<SpeakerProfile>, 254 speakers: List<SpeakerProfile>,
273 current: Long?, 255 current: Long?,