Commit 660b6ec5b109ad84d519be320cc0f78d9ae68e91
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>
Showing
2 changed files
with
187 additions
and
39 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| @@ -53,8 +53,10 @@ import androidx.compose.material3.Text | @@ -53,8 +53,10 @@ import androidx.compose.material3.Text | ||
| 53 | import androidx.compose.material3.TextButton | 53 | import androidx.compose.material3.TextButton |
| 54 | import androidx.compose.material3.rememberModalBottomSheetState | 54 | import androidx.compose.material3.rememberModalBottomSheetState |
| 55 | import androidx.compose.runtime.Composable | 55 | import androidx.compose.runtime.Composable |
| 56 | +import androidx.compose.runtime.LaunchedEffect | ||
| 56 | import androidx.compose.runtime.collectAsState | 57 | import androidx.compose.runtime.collectAsState |
| 57 | import androidx.compose.runtime.getValue | 58 | import androidx.compose.runtime.getValue |
| 59 | +import androidx.compose.runtime.mutableStateMapOf | ||
| 58 | import androidx.compose.runtime.mutableStateOf | 60 | import androidx.compose.runtime.mutableStateOf |
| 59 | import androidx.compose.runtime.produceState | 61 | import androidx.compose.runtime.produceState |
| 60 | import androidx.compose.runtime.remember | 62 | import androidx.compose.runtime.remember |
| @@ -88,6 +90,7 @@ internal fun ContactsTab(repo: Repository) { | @@ -88,6 +90,7 @@ internal fun ContactsTab(repo: Repository) { | ||
| 88 | var profilePersonId by remember { mutableStateOf<Long?>(null) } | 90 | var profilePersonId by remember { mutableStateOf<Long?>(null) } |
| 89 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } | 91 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 90 | var showPicker by remember { mutableStateOf(false) } | 92 | var showPicker by remember { mutableStateOf(false) } |
| 93 | + var showPhonePicker by remember { mutableStateOf(false) } | ||
| 91 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 | 94 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 92 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) | 95 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| 93 | 96 | ||
| @@ -98,7 +101,12 @@ internal fun ContactsTab(repo: Repository) { | @@ -98,7 +101,12 @@ internal fun ContactsTab(repo: Repository) { | ||
| 98 | onBack = { profilePersonId = null }, | 101 | onBack = { profilePersonId = null }, |
| 99 | onOpenPhoto = { detailPhotoId = it }, | 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 | else -> ContactList( | 110 | else -> ContactList( |
| 103 | vm = vm, persons = managed, | 111 | vm = vm, persons = managed, |
| 104 | onOpen = { vm.touchPerson(it); profilePersonId = it }, | 112 | onOpen = { vm.touchPerson(it); profilePersonId = it }, |
| @@ -420,30 +428,13 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { | @@ -420,30 +428,13 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { | ||
| 420 | /** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ | 428 | /** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ |
| 421 | @OptIn(ExperimentalFoundationApi::class) | 429 | @OptIn(ExperimentalFoundationApi::class) |
| 422 | @Composable | 430 | @Composable |
| 423 | -private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | 431 | +private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> Unit) { |
| 424 | val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) | 432 | val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) |
| 425 | val enrollReady = remember { vm.wechatEnrollReady() } | 433 | val enrollReady = remember { vm.wechatEnrollReady() } |
| 426 | var enrollTapped by remember { mutableStateOf(false) } | 434 | var enrollTapped by remember { mutableStateOf(false) } |
| 427 | var query by remember { mutableStateOf("") } | 435 | var query by remember { mutableStateOf("") } |
| 428 | val managedCount = all.count { it.managed } | 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 | val sections = remember(all, query) { | 438 | val sections = remember(all, query) { |
| 448 | val q = query.trim() | 439 | val q = query.trim() |
| 449 | val filtered = if (q.isBlank()) all else all.filter { p -> | 440 | val filtered = if (q.isBlank()) all else all.filter { p -> |
| @@ -469,7 +460,7 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | @@ -469,7 +460,7 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | ||
| 469 | placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, | 460 | placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, |
| 470 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | 461 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), |
| 471 | ) | 462 | ) |
| 472 | - // 两源导入:微信(Mac 推过来的 enroll 数据)+ 手机系统通讯录。导入进池子,下面勾选。 | 463 | + // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。 |
| 473 | Row( | 464 | Row( |
| 474 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), | 465 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), |
| 475 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | 466 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| @@ -477,10 +468,9 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | @@ -477,10 +468,9 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { | ||
| 477 | if (enrollReady) { | 468 | if (enrollReady) { |
| 478 | Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") } | 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 | else "勾选要纳入「我的人物」的联系人" | 474 | else "勾选要纳入「我的人物」的联系人" |
| 485 | Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) | 475 | Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) |
| 486 | HorizontalDivider() | 476 | HorizontalDivider() |
| @@ -528,3 +518,132 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { | @@ -528,3 +518,132 @@ private fun PickerRow(p: Person, onToggle: () -> 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,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 | val app = getApplication<Application>() | 116 | val app = getApplication<Application>() |
| 122 | viewModelScope.launch { | 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 | try { | 120 | try { |
| 126 | val proj = arrayOf( | 121 | val proj = arrayOf( |
| 127 | android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID, | 122 | android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID, |
| @@ -143,17 +138,39 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -143,17 +138,39 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 143 | if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条 | 138 | if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条 |
| 144 | val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty() | 139 | val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty() |
| 145 | if (name.isBlank()) continue | 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 | val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null | 143 | val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null |
| 149 | val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() } | 144 | val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() } |
| 150 | val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar()) | 145 | val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar()) |
| 151 | ?.takeIf { it in 'A'..'Z' }?.toString() | 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 | } catch (t: Throwable) { | 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 | count | 175 | count |
| 159 | } | 176 | } |
| @@ -163,20 +180,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -163,20 +180,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 163 | 180 | ||
| 164 | /** 返回 true = 本轮新建了一行(用于计数)。 */ | 181 | /** 返回 true = 本轮新建了一行(用于计数)。 */ |
| 165 | private suspend fun upsertPhoneContact( | 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 | ): Boolean { | 184 | ): Boolean { |
| 168 | val ref = "phone:$cid" | 185 | val ref = "phone:$cid" |
| 169 | repo.persons.getByContactRef(ref)?.let { | 186 | repo.persons.getByContactRef(ref)?.let { |
| 170 | repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit) | 187 | repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit) |
| 188 | + if (managed) repo.persons.setManaged(it.id, true) | ||
| 171 | return false | 189 | return false |
| 172 | } | 190 | } |
| 173 | val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name) | 191 | val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name) |
| 174 | if (merge != null) { | 192 | if (merge != null) { |
| 175 | if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone) | 193 | if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone) |
| 194 | + if (managed) repo.persons.setManaged(merge.id, true) | ||
| 176 | return false | 195 | return false |
| 177 | } | 196 | } |
| 178 | repo.persons.insert( | 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 | return true | 200 | return true |
| 182 | } | 201 | } |
| @@ -308,6 +327,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -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 | sealed class VoiceEnroll { | 341 | sealed class VoiceEnroll { |
| 313 | object Idle : VoiceEnroll() | 342 | object Idle : VoiceEnroll() |