Commit 660b6ec5b109ad84d519be320cc0f78d9ae68e91

Authored by yibo
1 parent 398bc940

feat(lifelog/photolog): 手机通讯录改独立多选页(读系统联系人→勾选→导入)

之前「📱手机通讯录」一点就全量导进池子,改为跟微信导入一致的列表+多选体验:
- 新增 PhoneContactPickerScreen:READ_CONTACTS 后读系统联系人成内存列表(不入库),
  按拼音 A–Z 分组+搜索,每行显示 人名+手机号 带复选框,支持全选/取消全选
- 勾选「导入选中」才入库,直接进「我管理的人物」(managed=true,不用再去池子勾第二遍)
- VM: importPhoneContacts(全量) 拆成 loadPhoneContacts(读列表)+importSelectedPhoneContacts(导选中)
- 保留按手机号/姓名跨源自动合并去重
- 「通讯录」池页的手机入口改为跳转多选页(onOpenPhonePicker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
... ... @@ -53,8 +53,10 @@ import androidx.compose.material3.Text
53 53 import androidx.compose.material3.TextButton
54 54 import androidx.compose.material3.rememberModalBottomSheetState
55 55 import androidx.compose.runtime.Composable
  56 +import androidx.compose.runtime.LaunchedEffect
56 57 import androidx.compose.runtime.collectAsState
57 58 import androidx.compose.runtime.getValue
  59 +import androidx.compose.runtime.mutableStateMapOf
58 60 import androidx.compose.runtime.mutableStateOf
59 61 import androidx.compose.runtime.produceState
60 62 import androidx.compose.runtime.remember
... ... @@ -88,6 +90,7 @@ internal fun ContactsTab(repo: Repository) {
88 90 var profilePersonId by remember { mutableStateOf<Long?>(null) }
89 91 var detailPhotoId by remember { mutableStateOf<Long?>(null) }
90 92 var showPicker by remember { mutableStateOf(false) }
  93 + var showPhonePicker by remember { mutableStateOf(false) }
91 94 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。
92 95 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
93 96  
... ... @@ -98,7 +101,12 @@ internal fun ContactsTab(repo: Repository) {
98 101 onBack = { profilePersonId = null },
99 102 onOpenPhoto = { detailPhotoId = it },
100 103 )
101   - showPicker -> WeChatPickerScreen(vm = vm, onBack = { showPicker = false })
  104 + showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false })
  105 + showPicker -> WeChatPickerScreen(
  106 + vm = vm,
  107 + onBack = { showPicker = false },
  108 + onOpenPhonePicker = { showPhonePicker = true },
  109 + )
102 110 else -> ContactList(
103 111 vm = vm, persons = managed,
104 112 onOpen = { vm.touchPerson(it); profilePersonId = it },
... ... @@ -420,30 +428,13 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) {
420 428 /** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */
421 429 @OptIn(ExperimentalFoundationApi::class)
422 430 @Composable
423   -private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) {
  431 +private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) {
424 432 val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList())
425 433 val enrollReady = remember { vm.wechatEnrollReady() }
426 434 var enrollTapped by remember { mutableStateOf(false) }
427 435 var query by remember { mutableStateOf("") }
428 436 val managedCount = all.count { it.managed }
429 437  
430   - val ctx = LocalContext.current
431   - var phoneStatus by remember { mutableStateOf<String?>(null) }
432   - val runPhoneImport = {
433   - phoneStatus = "导入中…"
434   - vm.importPhoneContacts { n -> phoneStatus = "已导入手机通讯录(新增 $n)" }
435   - }
436   - val contactsLauncher = rememberLauncherForActivityResult(
437   - ActivityResultContracts.RequestPermission(),
438   - ) { granted -> if (granted) runPhoneImport() else phoneStatus = "未授予通讯录权限" }
439   - val importPhone = {
440   - if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
441   - runPhoneImport()
442   - } else {
443   - contactsLauncher.launch(Manifest.permission.READ_CONTACTS)
444   - }
445   - }
446   -
447 438 val sections = remember(all, query) {
448 439 val q = query.trim()
449 440 val filtered = if (q.isBlank()) all else all.filter { p ->
... ... @@ -469,7 +460,7 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit) {
469 460 placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true,
470 461 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
471 462 )
472   - // 两源导入:微信(Mac 推过来的 enroll 数据)+ 手机系统通讯录。导入进池子,下面勾选。
  463 + // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。
473 464 Row(
474 465 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp),
475 466 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
... ... @@ -477,10 +468,9 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -&gt; Unit) {
477 468 if (enrollReady) {
478 469 Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") }
479 470 }
480   - OutlinedButton(onClick = importPhone) { Text("📱 手机通讯录") }
  471 + OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") }
481 472 }
482   - val hint = phoneStatus
483   - ?: if (enrollTapped) "微信后台导入中,联系人会陆续出现…"
  473 + val hint = if (enrollTapped) "微信后台导入中,联系人会陆续出现…"
484 474 else "勾选要纳入「我的人物」的联系人"
485 475 Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp))
486 476 HorizontalDivider()
... ... @@ -528,3 +518,132 @@ private fun PickerRow(p: Person, onToggle: () -&gt; Unit) {
528 518 }
529 519 }
530 520 }
  521 +
  522 +/**
  523 + * 手机系统通讯录多选页:读系统联系人 → 列表(姓名 + 手机号) → 勾选 → 「导入选中」进「我的人物」。
  524 + * 跟微信导入一致的列表 + 多选体验,但选择发生在系统通讯录这一层(选了才入库)。
  525 + */
  526 +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
  527 +@Composable
  528 +private fun PhoneContactPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) {
  529 + val ctx = LocalContext.current
  530 + var granted by remember {
  531 + mutableStateOf(
  532 + ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED,
  533 + )
  534 + }
  535 + var contacts by remember { mutableStateOf<List<PhoneContact>?>(null) }
  536 + val selected = remember { mutableStateMapOf<Long, Boolean>() }
  537 + var query by remember { mutableStateOf("") }
  538 + var status by remember { mutableStateOf<String?>(null) }
  539 +
  540 + val permLauncher = rememberLauncherForActivityResult(
  541 + ActivityResultContracts.RequestPermission(),
  542 + ) { g -> granted = g }
  543 +
  544 + LaunchedEffect(granted) {
  545 + if (granted) {
  546 + if (contacts == null) vm.loadPhoneContacts { contacts = it }
  547 + } else {
  548 + permLauncher.launch(Manifest.permission.READ_CONTACTS)
  549 + }
  550 + }
  551 +
  552 + val list = contacts.orEmpty()
  553 + val sections = remember(list, query) {
  554 + val q = query.trim()
  555 + val filtered = if (q.isBlank()) list else list.filter {
  556 + it.name.contains(q, true) || it.phoneDisplay?.contains(q) == true || it.pinyin?.contains(q, true) == true
  557 + }
  558 + filtered.sortedWith(compareBy({ phoneLetter(it) }, { it.pinyin ?: it.name }))
  559 + .groupBy { phoneLetter(it) }
  560 + .toSortedMap(compareBy { if (it == "#") "[" else it })
  561 + }
  562 + val selCount = selected.count { it.value }
  563 +
  564 + Column(modifier = Modifier.fillMaxSize()) {
  565 + Row(
  566 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
  567 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
  568 + ) {
  569 + TextButton(onClick = onBack) { Text("← 返回") }
  570 + Text("手机通讯录", style = MaterialTheme.typography.titleMedium)
  571 + }
  572 + OutlinedTextField(
  573 + value = query, onValueChange = { query = it },
  574 + placeholder = { Text("搜索(共 ${list.size})") }, singleLine = true,
  575 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
  576 + )
  577 + Row(
  578 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp),
  579 + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
  580 + ) {
  581 + Button(
  582 + enabled = selCount > 0,
  583 + onClick = {
  584 + val sel = list.filter { selected[it.id] == true }
  585 + status = "导入中…"
  586 + vm.importSelectedPhoneContacts(sel) { n ->
  587 + status = "已导入 ${sel.size} 人(新增 $n)到「我的人物」"
  588 + selected.clear()
  589 + }
  590 + },
  591 + ) { Text("导入选中($selCount)") }
  592 + if (list.isNotEmpty()) {
  593 + TextButton(onClick = {
  594 + if (selCount == list.size) selected.clear()
  595 + else list.forEach { selected[it.id] = true }
  596 + }) { Text(if (selCount == list.size) "取消全选" else "全选") }
  597 + }
  598 + }
  599 + status?.let { Text(it, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) }
  600 + HorizontalDivider()
  601 + when {
  602 + !granted -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
  603 + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
  604 + Text("需要通讯录权限才能读取手机联系人。", style = MaterialTheme.typography.bodyMedium)
  605 + Button(onClick = { permLauncher.launch(Manifest.permission.READ_CONTACTS) }) { Text("去授权") }
  606 + }
  607 + }
  608 + contacts == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
  609 + list.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
  610 + Text("手机通讯录里没有带号码的联系人。", style = MaterialTheme.typography.bodyMedium)
  611 + }
  612 + else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
  613 + sections.forEach { (letter, group) ->
  614 + stickyHeader {
  615 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
  616 + Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp))
  617 + }
  618 + }
  619 + items(group, key = { it.id }) { pc ->
  620 + PhoneContactRow(pc, checked = selected[pc.id] == true) {
  621 + selected[pc.id] = !(selected[pc.id] ?: false)
  622 + }
  623 + }
  624 + }
  625 + }
  626 + }
  627 + }
  628 +}
  629 +
  630 +private fun phoneLetter(pc: PhoneContact): String {
  631 + val c = (pc.pyInitial?.firstOrNull() ?: pc.name.firstOrNull())?.uppercaseChar()
  632 + return if (c != null && c in 'A'..'Z') c.toString() else "#"
  633 +}
  634 +
  635 +@Composable
  636 +private fun PhoneContactRow(pc: PhoneContact, checked: Boolean, onToggle: () -> Unit) {
  637 + Row(
  638 + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 12.dp, vertical = 5.dp),
  639 + verticalAlignment = Alignment.CenterVertically,
  640 + horizontalArrangement = Arrangement.spacedBy(10.dp),
  641 + ) {
  642 + Checkbox(checked = checked, onCheckedChange = { onToggle() })
  643 + Box(modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant))
  644 + Column(modifier = Modifier.weight(1f)) {
  645 + Text(pc.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall)
  646 + pc.phoneDisplay?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }
  647 + }
  648 + }
  649 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
... ... @@ -109,19 +109,14 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
109 109 }
110 110  
111 111 /**
112   - * 从手机系统通讯录导入联系人到「池子」(managed=false,跟微信一致,用户再勾选)。
113   - * 跨源去重(用户选择「按手机号/姓名自动合并」):
114   - * 1) 已按 contactRef="phone:<id>" 建过 → 刷新资料;
115   - * 2) 否则手机号命中已有人物 → 只补手机号,不另建;
116   - * 3) 否则姓名精确命中 → 只补手机号,不另建;
117   - * 4) 都没有 → 新建。
118   - * 调用方需先拿到 READ_CONTACTS。回调返回本轮新建数。
  112 + * 读手机系统通讯录 → 内存列表(不入库),供「手机通讯录」多选页展示。
  113 + * 一个联系人多号码只取第一条;无名字的跳过。调用方需先拿到 READ_CONTACTS。
119 114 */
120   - fun importPhoneContacts(onDone: (Int) -> Unit) {
  115 + fun loadPhoneContacts(onResult: (List<PhoneContact>) -> Unit) {
121 116 val app = getApplication<Application>()
122 117 viewModelScope.launch {
123   - val created = withContext(Dispatchers.IO) {
124   - var count = 0
  118 + val list = withContext(Dispatchers.IO) {
  119 + val out = ArrayList<PhoneContact>()
125 120 try {
126 121 val proj = arrayOf(
127 122 android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
... ... @@ -143,17 +138,39 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
143 138 if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条
144 139 val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty()
145 140 if (name.isBlank()) continue
146   - val phone = (if (numIdx >= 0) c.getString(numIdx) else null)
147   - ?.filter { it.isDigit() }?.takeIf { it.isNotBlank() }
  141 + val phoneRaw = (if (numIdx >= 0) c.getString(numIdx) else null)?.trim()?.takeIf { it.isNotBlank() }
  142 + val phone = phoneRaw?.filter { it.isDigit() }?.takeIf { it.isNotBlank() }
148 143 val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null
149 144 val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() }
150 145 val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar())
151 146 ?.takeIf { it in 'A'..'Z' }?.toString()
152   - if (upsertPhoneContact(cid, name, phone, pinyin, pyInit)) count++
  147 + out.add(PhoneContact(cid, name, phoneRaw, phone, pinyin, pyInit))
153 148 }
154 149 }
155 150 } catch (t: Throwable) {
156   - Log.w("PhotologVM", "phone import failed: ${t.message}")
  151 + Log.w("PhotologVM", "load phone contacts failed: ${t.message}")
  152 + }
  153 + out
  154 + }
  155 + onResult(list)
  156 + }
  157 + }
  158 +
  159 + /**
  160 + * 把多选页勾选的手机联系人导入「我管理的人物」(managed=true,因为已是人工勾选)。
  161 + * 跨源去重(用户选择「按手机号/姓名自动合并」):
  162 + * 1) 已按 contactRef="phone:<id>" 建过 → 刷新资料 + 纳入管理;
  163 + * 2) 否则手机号命中已有人物 → 补手机号 + 纳入管理,不另建;
  164 + * 3) 否则姓名精确命中 → 补手机号 + 纳入管理,不另建;
  165 + * 4) 都没有 → 新建(managed=true)。
  166 + * 回调返回本轮新建数。
  167 + */
  168 + fun importSelectedPhoneContacts(selected: List<PhoneContact>, onDone: (Int) -> Unit) {
  169 + viewModelScope.launch {
  170 + val created = withContext(Dispatchers.IO) {
  171 + var count = 0
  172 + for (pc in selected) {
  173 + if (upsertPhoneContact(pc.id, pc.name, pc.phone, pc.pinyin, pc.pyInitial, managed = true)) count++
157 174 }
158 175 count
159 176 }
... ... @@ -163,20 +180,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
163 180  
164 181 /** 返回 true = 本轮新建了一行(用于计数)。 */
165 182 private suspend fun upsertPhoneContact(
166   - cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?,
  183 + cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?, managed: Boolean,
167 184 ): Boolean {
168 185 val ref = "phone:$cid"
169 186 repo.persons.getByContactRef(ref)?.let {
170 187 repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit)
  188 + if (managed) repo.persons.setManaged(it.id, true)
171 189 return false
172 190 }
173 191 val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name)
174 192 if (merge != null) {
175 193 if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone)
  194 + if (managed) repo.persons.setManaged(merge.id, true)
176 195 return false
177 196 }
178 197 repo.persons.insert(
179   - Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit),
  198 + Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit, managed = managed),
180 199 )
181 200 return true
182 201 }
... ... @@ -308,6 +327,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
308 327 }
309 328 }
310 329  
  330 +/** 手机系统通讯录里的一条(内存态,未入库)。phoneDisplay 留原格式给 UI,phone 是纯数字去重键。 */
  331 +data class PhoneContact(
  332 + val id: Long,
  333 + val name: String,
  334 + val phoneDisplay: String?,
  335 + val phone: String?,
  336 + val pinyin: String?,
  337 + val pyInitial: String?,
  338 +)
  339 +
311 340 /** 「录一段绑声纹」对话框的状态机。 */
312 341 sealed class VoiceEnroll {
313 342 object Idle : VoiceEnroll()
... ...