diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 8f4dedc..caaef3b 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -82,20 +83,26 @@ internal fun ContactsTab(repo: Repository) { val vm: PhotologViewModel = viewModel() var profilePersonId by remember { mutableStateOf(null) } var detailPhotoId by remember { mutableStateOf(null) } - val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + var showPicker by remember { mutableStateOf(false) } + // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 + val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) val pid = profilePersonId - if (pid != null) { - PersonProfileScreen( + when { + pid != null -> PersonProfileScreen( vm = vm, personId = pid, onBack = { profilePersonId = null }, onOpenPhoto = { detailPhotoId = it }, ) - } else { - ContactList(vm = vm, persons = persons, onOpen = { vm.touchPerson(it); profilePersonId = it }) + showPicker -> WeChatPickerScreen(vm = vm, onBack = { showPicker = false }) + else -> ContactList( + vm = vm, persons = managed, + onOpen = { vm.touchPerson(it); profilePersonId = it }, + onOpenPicker = { showPicker = true }, + ) } detailPhotoId?.let { id -> - PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) + PhotoDetailSheet(vm = vm, photoId = id, persons = managed, onDismiss = { detailPhotoId = null }) } } @@ -312,12 +319,10 @@ private fun TagFaceDialog( } } -/** 通讯录样式:搜索 + 拼音 A–Z 分组 + 头像,点进档案页。 */ +/** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit) { - val enrollReady = remember { vm.wechatEnrollReady() } - var enrollTapped by remember { mutableStateOf(false) } +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit) { var query by remember { mutableStateOf("") } val sections = remember(persons, query) { @@ -331,7 +336,6 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L .groupBy { sectionLetter(it) } .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 } - // 最近联系人(点过 / 有过互动),置顶;搜索时不显示。 val recent = remember(persons, query) { if (query.isNotBlank()) emptyList() else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) @@ -340,25 +344,20 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( value = query, onValueChange = { query = it }, - placeholder = { Text("搜索联系人(共 ${persons.size})") }, singleLine = true, + placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), ) - if (enrollReady) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } - Text( - if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", - style = MaterialTheme.typography.labelSmall, - ) - } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = onOpenPicker) { Text("📇 微信通讯录") } + Text("勾选你关心的人加入管理", style = MaterialTheme.typography.labelSmall) } HorizontalDivider() if (persons.isEmpty()) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text("还没有联系人。点「从微信导入联系人」,或在照片里标注人脸。", style = MaterialTheme.typography.bodyMedium) + Text("还没有管理的人物。\n点上方「微信通讯录」勾选你关心的人,或在照片里标注人脸、绑声纹会自动加入。", style = MaterialTheme.typography.bodyMedium) } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { @@ -413,3 +412,89 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { } } } + +/** 微信通讯录(全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { + val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + val enrollReady = remember { vm.wechatEnrollReady() } + var enrollTapped by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + val managedCount = all.count { it.managed } + + val sections = remember(all, query) { + val q = query.trim() + val filtered = if (q.isBlank()) all else all.filter { p -> + p.name.contains(q, true) || p.nickname?.contains(q, true) == true || + p.remark?.contains(q, true) == true || p.alias?.contains(q, true) == true || + p.pinyin?.contains(q, true) == true + } + filtered.sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) + .groupBy { sectionLetter(it) } + .toSortedMap(compareBy { if (it == "#") "[" else it }) + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onBack) { Text("← 返回") } + Text("微信通讯录", style = MaterialTheme.typography.titleMedium) + } + OutlinedTextField( + value = query, onValueChange = { query = it }, + placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) + if (enrollReady && all.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") } + Text(if (enrollTapped) "后台导入中,联系人会陆续出现…" else "导入全部真人联系人", style = MaterialTheme.typography.labelSmall) + } + } else { + Text("勾选要纳入「我的人物」的联系人", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) + } + HorizontalDivider() + LazyColumn(modifier = Modifier.fillMaxSize()) { + sections.forEach { (letter, group) -> + stickyHeader { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) + } + } + items(group, key = { it.id }) { p -> + PickerRow(p) { vm.setManaged(p.id, !p.managed) } + } + } + } + } +} + +@Composable +private fun PickerRow(p: Person, onToggle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 12.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Checkbox(checked = p.managed, onCheckedChange = { onToggle() }) + if (p.avatarPath != null) { + AsyncImage( + model = File(p.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + ) + } else { + Box(modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + } + Column(modifier = Modifier.weight(1f)) { + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) + val sub = p.remark?.takeIf { it.isNotBlank() && it != p.name } ?: p.nickname?.takeIf { it != p.name } + if (sub != null) Text(sub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) + } + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index b7c4ec3..5cbebcc 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -59,13 +59,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { fun createPersonAndTag(faceId: Long, name: String) { viewModelScope.launch { - val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim())) } + // 手动建的人 = 我关心的 → 直接 managed。 + val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim(), managed = true)) } FaceMatcher.assignAndBackfill(repo, faceId, pid) } } fun tagFace(faceId: Long, personId: Long) { - viewModelScope.launch { FaceMatcher.assignAndBackfill(repo, faceId, personId) } + viewModelScope.launch { + repo.persons.setManaged(personId, true) // 标了脸 = 我关心 → 纳入管理 + FaceMatcher.assignAndBackfill(repo, faceId, personId) + } + } + + /** 在「微信通讯录」里勾选 / 取消管理某联系人。 */ + fun setManaged(personId: Long, managed: Boolean) { + viewModelScope.launch { repo.persons.setManaged(personId, managed) } } fun clearFace(faceId: Long) { @@ -85,7 +94,10 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { fun bindVoice(personId: Long, speakerId: Long?) { viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) - if (speakerId != null) repo.persons.touch(personId, System.currentTimeMillis()) + if (speakerId != null) { + repo.persons.touch(personId, System.currentTimeMillis()) + repo.persons.setManaged(personId, true) // 绑了声纹 = 我关心 → 纳入管理 + } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index fdf42d7..88d00e5 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -235,6 +235,13 @@ interface PersonDao { @Query("SELECT * FROM persons ORDER BY name ASC") fun observeAll(): Flow> + /** 「我管理的人物」:managed=1(人物 tab 用)。 */ + @Query("SELECT * FROM persons WHERE managed = 1 ORDER BY name ASC") + fun observeManaged(): Flow> + + @Query("UPDATE persons SET managed = :managed WHERE id = :id") + suspend fun setManaged(id: Long, managed: Boolean) + @Query("SELECT * FROM persons ORDER BY name ASC") suspend fun all(): List