Commit bf83fe61ea371e997b405692c25cb13e74512189

Authored by yibo
1 parent 8660d567

feat(lifelog/photolog): 微信联系人池 ↔ 我管理的人物 分离

人物 tab 只显示 managed=true 的人;4357 个微信联系人退为「池子」。
新增「微信通讯录」选人页:全部联系人 A–Z + 搜索 + 勾选 → setManaged。
标人脸 / 绑声纹 / 手动建人自动置 managed=true。observeManaged 驱动列表。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.material3.AlertDialog @@ -32,6 +32,7 @@ import androidx.compose.material3.AlertDialog
32 import androidx.compose.material3.AssistChip 32 import androidx.compose.material3.AssistChip
33 import androidx.compose.material3.Button 33 import androidx.compose.material3.Button
34 import androidx.compose.material3.Card 34 import androidx.compose.material3.Card
  35 +import androidx.compose.material3.Checkbox
35 import androidx.compose.material3.CircularProgressIndicator 36 import androidx.compose.material3.CircularProgressIndicator
36 import androidx.compose.material3.ExperimentalMaterial3Api 37 import androidx.compose.material3.ExperimentalMaterial3Api
37 import androidx.compose.material3.FilterChip 38 import androidx.compose.material3.FilterChip
@@ -82,20 +83,26 @@ internal fun ContactsTab(repo: Repository) { @@ -82,20 +83,26 @@ internal fun ContactsTab(repo: Repository) {
82 val vm: PhotologViewModel = viewModel() 83 val vm: PhotologViewModel = viewModel()
83 var profilePersonId by remember { mutableStateOf<Long?>(null) } 84 var profilePersonId by remember { mutableStateOf<Long?>(null) }
84 var detailPhotoId by remember { mutableStateOf<Long?>(null) } 85 var detailPhotoId by remember { mutableStateOf<Long?>(null) }
85 - val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) 86 + var showPicker by remember { mutableStateOf(false) }
  87 + // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。
  88 + val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
86 89
87 val pid = profilePersonId 90 val pid = profilePersonId
88 - if (pid != null) {  
89 - PersonProfileScreen( 91 + when {
  92 + pid != null -> PersonProfileScreen(
90 vm = vm, personId = pid, 93 vm = vm, personId = pid,
91 onBack = { profilePersonId = null }, 94 onBack = { profilePersonId = null },
92 onOpenPhoto = { detailPhotoId = it }, 95 onOpenPhoto = { detailPhotoId = it },
93 ) 96 )
94 - } else {  
95 - ContactList(vm = vm, persons = persons, onOpen = { vm.touchPerson(it); profilePersonId = it }) 97 + showPicker -> WeChatPickerScreen(vm = vm, onBack = { showPicker = false })
  98 + else -> ContactList(
  99 + vm = vm, persons = managed,
  100 + onOpen = { vm.touchPerson(it); profilePersonId = it },
  101 + onOpenPicker = { showPicker = true },
  102 + )
96 } 103 }
97 detailPhotoId?.let { id -> 104 detailPhotoId?.let { id ->
98 - PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) 105 + PhotoDetailSheet(vm = vm, photoId = id, persons = managed, onDismiss = { detailPhotoId = null })
99 } 106 }
100 } 107 }
101 108
@@ -312,12 +319,10 @@ private fun TagFaceDialog( @@ -312,12 +319,10 @@ private fun TagFaceDialog(
312 } 319 }
313 } 320 }
314 321
315 -/** 通讯录样式:搜索 + 拼音 A–Z 分组 + 头像,点进档案页。 */ 322 +/** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */
316 @OptIn(ExperimentalFoundationApi::class) 323 @OptIn(ExperimentalFoundationApi::class)
317 @Composable 324 @Composable
318 -private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit) {  
319 - val enrollReady = remember { vm.wechatEnrollReady() }  
320 - var enrollTapped by remember { mutableStateOf(false) } 325 +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit) {
321 var query by remember { mutableStateOf("") } 326 var query by remember { mutableStateOf("") }
322 327
323 val sections = remember(persons, query) { 328 val sections = remember(persons, query) {
@@ -331,7 +336,6 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -331,7 +336,6 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
331 .groupBy { sectionLetter(it) } 336 .groupBy { sectionLetter(it) }
332 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 337 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后
333 } 338 }
334 - // 最近联系人(点过 / 有过互动),置顶;搜索时不显示。  
335 val recent = remember(persons, query) { 339 val recent = remember(persons, query) {
336 if (query.isNotBlank()) emptyList() 340 if (query.isNotBlank()) emptyList()
337 else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) 341 else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12)
@@ -340,25 +344,20 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -340,25 +344,20 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
340 Column(modifier = Modifier.fillMaxSize()) { 344 Column(modifier = Modifier.fillMaxSize()) {
341 OutlinedTextField( 345 OutlinedTextField(
342 value = query, onValueChange = { query = it }, 346 value = query, onValueChange = { query = it },
343 - placeholder = { Text("搜索联系人(共 ${persons.size})") }, singleLine = true, 347 + placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true,
344 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), 348 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
345 ) 349 )
346 - if (enrollReady) {  
347 - Row(  
348 - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp),  
349 - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),  
350 - ) {  
351 - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") }  
352 - Text(  
353 - if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人",  
354 - style = MaterialTheme.typography.labelSmall,  
355 - )  
356 - } 350 + Row(
  351 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp),
  352 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
  353 + ) {
  354 + Button(onClick = onOpenPicker) { Text("📇 微信通讯录") }
  355 + Text("勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall)
357 } 356 }
358 HorizontalDivider() 357 HorizontalDivider()
359 if (persons.isEmpty()) { 358 if (persons.isEmpty()) {
360 Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { 359 Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
361 - Text("还没有联系人。点「从微信导入联系人」,或在照片里标注人脸。", style = MaterialTheme.typography.bodyMedium) 360 + Text("还没有管理的人物。\n点上方「微信通讯录」勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium)
362 } 361 }
363 } else { 362 } else {
364 LazyColumn(modifier = Modifier.fillMaxSize()) { 363 LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -413,3 +412,89 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) { @@ -413,3 +412,89 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) {
413 } 412 }
414 } 413 }
415 } 414 }
  415 +
  416 +/** 微信通讯录(全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */
  417 +@OptIn(ExperimentalFoundationApi::class)
  418 +@Composable
  419 +private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) {
  420 + val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList())
  421 + val enrollReady = remember { vm.wechatEnrollReady() }
  422 + var enrollTapped by remember { mutableStateOf(false) }
  423 + var query by remember { mutableStateOf("") }
  424 + val managedCount = all.count { it.managed }
  425 +
  426 + val sections = remember(all, query) {
  427 + val q = query.trim()
  428 + val filtered = if (q.isBlank()) all else all.filter { p ->
  429 + p.name.contains(q, true) || p.nickname?.contains(q, true) == true ||
  430 + p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true ||
  431 + p.pinyin?.contains(q, true) == true
  432 + }
  433 + filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name }))
  434 + .groupBy { sectionLetter(it) }
  435 + .toSortedMap(compareBy { if (it == "#") "[" else it })
  436 + }
  437 +
  438 + Column(modifier = Modifier.fillMaxSize()) {
  439 + Row(
  440 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
  441 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
  442 + ) {
  443 + TextButton(onClick = onBack) { Text("← 返回") }
  444 + Text("微信通讯录", style = MaterialTheme.typography.titleMedium)
  445 + }
  446 + OutlinedTextField(
  447 + value = query, onValueChange = { query = it },
  448 + placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true,
  449 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
  450 + )
  451 + if (enrollReady && all.isEmpty()) {
  452 + Row(
  453 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp),
  454 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
  455 + ) {
  456 + Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") }
  457 + Text(if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", style = MaterialTheme.typography.labelSmall)
  458 + }
  459 + } else {
  460 + Text("勾选要纳入「我的人物」的联系人", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp))
  461 + }
  462 + HorizontalDivider()
  463 + LazyColumn(modifier = Modifier.fillMaxSize()) {
  464 + sections.forEach { (letter, group) ->
  465 + stickyHeader {
  466 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
  467 + Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp))
  468 + }
  469 + }
  470 + items(group, key = { it.id }) { p ->
  471 + PickerRow(p) { vm.setManaged(p.id, !p.managed) }
  472 + }
  473 + }
  474 + }
  475 + }
  476 +}
  477 +
  478 +@Composable
  479 +private fun PickerRow(p: Person, onToggle: () -> Unit) {
  480 + Row(
  481 + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 12.dp, vertical = 5.dp),
  482 + verticalAlignment = Alignment.CenterVertically,
  483 + horizontalArrangement = Arrangement.spacedBy(10.dp),
  484 + ) {
  485 + Checkbox(checked = p.managed, onCheckedChange = { onToggle() })
  486 + if (p.avatarPath != null) {
  487 + AsyncImage(
  488 + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop,
  489 + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)),
  490 + )
  491 + } else {
  492 + Box(modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant))
  493 + }
  494 + Column(modifier = Modifier.weight(1f)) {
  495 + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall)
  496 + val sub = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name }
  497 + if (sub != null) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall)
  498 + }
  499 + }
  500 +}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -59,13 +59,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -59,13 +59,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
59 59
60 fun createPersonAndTag(faceId: Long, name: String) { 60 fun createPersonAndTag(faceId: Long, name: String) {
61 viewModelScope.launch { 61 viewModelScope.launch {
62 - val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim())) } 62 + // 手动建的人 = 我关心的 → 直接 managed。
  63 + val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim(), managed = true)) }
63 FaceMatcher.assignAndBackfill(repo, faceId, pid) 64 FaceMatcher.assignAndBackfill(repo, faceId, pid)
64 } 65 }
65 } 66 }
66 67
67 fun tagFace(faceId: Long, personId: Long) { 68 fun tagFace(faceId: Long, personId: Long) {
68 - viewModelScope.launch { FaceMatcher.assignAndBackfill(repo, faceId, personId) } 69 + viewModelScope.launch {
  70 + repo.persons.setManaged(personId, true) // 标了脸 = 我关心 → 纳入管理
  71 + FaceMatcher.assignAndBackfill(repo, faceId, personId)
  72 + }
  73 + }
  74 +
  75 + /** 在「微信通讯录」里勾选 / 取消管理某联系人。 */
  76 + fun setManaged(personId: Long, managed: Boolean) {
  77 + viewModelScope.launch { repo.persons.setManaged(personId, managed) }
69 } 78 }
70 79
71 fun clearFace(faceId: Long) { 80 fun clearFace(faceId: Long) {
@@ -85,7 +94,10 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -85,7 +94,10 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
85 fun bindVoice(personId: Long, speakerId: Long?) { 94 fun bindVoice(personId: Long, speakerId: Long?) {
86 viewModelScope.launch { 95 viewModelScope.launch {
87 repo.persons.linkVoice(personId, speakerId) 96 repo.persons.linkVoice(personId, speakerId)
88 - if (speakerId != null) repo.persons.touch(personId, System.currentTimeMillis()) 97 + if (speakerId != null) {
  98 + repo.persons.touch(personId, System.currentTimeMillis())
  99 + repo.persons.setManaged(personId, true) // 绑了声纹 = 我关心 → 纳入管理
  100 + }
89 } 101 }
90 } 102 }
91 103
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -235,6 +235,13 @@ interface PersonDao { @@ -235,6 +235,13 @@ interface PersonDao {
235 @Query("SELECT * FROM persons ORDER BY name ASC") 235 @Query("SELECT * FROM persons ORDER BY name ASC")
236 fun observeAll(): Flow<List<Person>> 236 fun observeAll(): Flow<List<Person>>
237 237
  238 + /** 「我管理的人物」:managed=1(人物 tab 用)。 */
  239 + @Query("SELECT * FROM persons WHERE managed = 1 ORDER BY name ASC")
  240 + fun observeManaged(): Flow<List<Person>>
  241 +
  242 + @Query("UPDATE persons SET managed = :managed WHERE id = :id")
  243 + suspend fun setManaged(id: Long, managed: Boolean)
  244 +
238 @Query("SELECT * FROM persons ORDER BY name ASC") 245 @Query("SELECT * FROM persons ORDER BY name ASC")
239 suspend fun all(): List<Person> 246 suspend fun all(): List<Person>
240 247