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 32 import androidx.compose.material3.AssistChip
33 33 import androidx.compose.material3.Button
34 34 import androidx.compose.material3.Card
  35 +import androidx.compose.material3.Checkbox
35 36 import androidx.compose.material3.CircularProgressIndicator
36 37 import androidx.compose.material3.ExperimentalMaterial3Api
37 38 import androidx.compose.material3.FilterChip
... ... @@ -82,20 +83,26 @@ internal fun ContactsTab(repo: Repository) {
82 83 val vm: PhotologViewModel = viewModel()
83 84 var profilePersonId by remember { mutableStateOf<Long?>(null) }
84 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 90 val pid = profilePersonId
88   - if (pid != null) {
89   - PersonProfileScreen(
  91 + when {
  92 + pid != null -> PersonProfileScreen(
90 93 vm = vm, personId = pid,
91 94 onBack = { profilePersonId = null },
92 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 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 319 }
313 320 }
314 321  
315   -/** 通讯录样式:搜索 + 拼音 A–Z 分组 + 头像,点进档案页。 */
  322 +/** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */
316 323 @OptIn(ExperimentalFoundationApi::class)
317 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 326 var query by remember { mutableStateOf("") }
322 327  
323 328 val sections = remember(persons, query) {
... ... @@ -331,7 +336,6 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
331 336 .groupBy { sectionLetter(it) }
332 337 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后
333 338 }
334   - // 最近联系人(点过 / 有过互动),置顶;搜索时不显示。
335 339 val recent = remember(persons, query) {
336 340 if (query.isNotBlank()) emptyList()
337 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 344 Column(modifier = Modifier.fillMaxSize()) {
341 345 OutlinedTextField(
342 346 value = query, onValueChange = { query = it },
343   - placeholder = { Text("搜索联系人(共 ${persons.size})") }, singleLine = true,
  347 + placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true,
344 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 357 HorizontalDivider()
359 358 if (persons.isEmpty()) {
360 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 362 } else {
364 363 LazyColumn(modifier = Modifier.fillMaxSize()) {
... ... @@ -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 59  
60 60 fun createPersonAndTag(faceId: Long, name: String) {
61 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 64 FaceMatcher.assignAndBackfill(repo, faceId, pid)
64 65 }
65 66 }
66 67  
67 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 80 fun clearFace(faceId: Long) {
... ... @@ -85,7 +94,10 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
85 94 fun bindVoice(personId: Long, speakerId: Long?) {
86 95 viewModelScope.launch {
87 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 235 @Query("SELECT * FROM persons ORDER BY name ASC")
236 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 245 @Query("SELECT * FROM persons ORDER BY name ASC")
239 246 suspend fun all(): List<Person>
240 247  
... ...