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 bc6f9d9..f3e342a 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 @@ -53,8 +53,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -88,6 +90,7 @@ internal fun ContactsTab(repo: Repository) { var profilePersonId by remember { mutableStateOf(null) } var detailPhotoId by remember { mutableStateOf(null) } var showPicker by remember { mutableStateOf(false) } + var showPhonePicker by remember { mutableStateOf(false) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) @@ -98,7 +101,12 @@ internal fun ContactsTab(repo: Repository) { onBack = { profilePersonId = null }, onOpenPhoto = { detailPhotoId = it }, ) - showPicker -> WeChatPickerScreen(vm = vm, onBack = { showPicker = false }) + showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) + showPicker -> WeChatPickerScreen( + vm = vm, + onBack = { showPicker = false }, + onOpenPhonePicker = { showPhonePicker = true }, + ) else -> ContactList( vm = vm, persons = managed, onOpen = { vm.touchPerson(it); profilePersonId = it }, @@ -420,30 +428,13 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { /** 通讯录(微信 + 手机两源合并的全部联系人池):勾选 → 纳入「我管理的人物」。搜索 + 拼音 A–Z。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { +private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit, onOpenPhonePicker: () -> 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 ctx = LocalContext.current - var phoneStatus by remember { mutableStateOf(null) } - val runPhoneImport = { - phoneStatus = "导入中…" - vm.importPhoneContacts { n -> phoneStatus = "已导入手机通讯录(新增 $n)" } - } - val contactsLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> if (granted) runPhoneImport() else phoneStatus = "未授予通讯录权限" } - val importPhone = { - if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { - runPhoneImport() - } else { - contactsLauncher.launch(Manifest.permission.READ_CONTACTS) - } - } - val sections = remember(all, query) { val q = query.trim() val filtered = if (q.isBlank()) all else all.filter { p -> @@ -469,7 +460,7 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { placeholder = { Text("搜索(共 ${all.size},已管理 $managedCount)") }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) - // 两源导入:微信(Mac 推过来的 enroll 数据)+ 手机系统通讯录。导入进池子,下面勾选。 + // 两源导入:微信(Mac 推过来的 enroll 数据,导入进池子后下面勾选)+ 手机系统通讯录(进多选页勾选导入)。 Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -477,10 +468,9 @@ private fun WeChatPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { if (enrollReady) { Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("💬 微信导入") } } - OutlinedButton(onClick = importPhone) { Text("📱 手机通讯录") } + OutlinedButton(onClick = onOpenPhonePicker) { Text("📱 手机通讯录") } } - val hint = phoneStatus - ?: if (enrollTapped) "微信后台导入中,联系人会陆续出现…" + val hint = if (enrollTapped) "微信后台导入中,联系人会陆续出现…" else "勾选要纳入「我的人物」的联系人" Text(hint, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) HorizontalDivider() @@ -528,3 +518,132 @@ private fun PickerRow(p: Person, onToggle: () -> Unit) { } } } + +/** + * 手机系统通讯录多选页:读系统联系人 → 列表(姓名 + 手机号) → 勾选 → 「导入选中」进「我的人物」。 + * 跟微信导入一致的列表 + 多选体验,但选择发生在系统通讯录这一层(选了才入库)。 + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun PhoneContactPickerScreen(vm: PhotologViewModel, onBack: () -> Unit) { + val ctx = LocalContext.current + var granted by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED, + ) + } + var contacts by remember { mutableStateOf?>(null) } + val selected = remember { mutableStateMapOf() } + var query by remember { mutableStateOf("") } + var status by remember { mutableStateOf(null) } + + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { g -> granted = g } + + LaunchedEffect(granted) { + if (granted) { + if (contacts == null) vm.loadPhoneContacts { contacts = it } + } else { + permLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + + val list = contacts.orEmpty() + val sections = remember(list, query) { + val q = query.trim() + val filtered = if (q.isBlank()) list else list.filter { + it.name.contains(q, true) || it.phoneDisplay?.contains(q) == true || it.pinyin?.contains(q, true) == true + } + filtered.sortedWith(compareBy({ phoneLetter(it) }, { it.pinyin ?: it.name })) + .groupBy { phoneLetter(it) } + .toSortedMap(compareBy { if (it == "#") "[" else it }) + } + val selCount = selected.count { it.value } + + 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("搜索(共 ${list.size})") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + enabled = selCount > 0, + onClick = { + val sel = list.filter { selected[it.id] == true } + status = "导入中…" + vm.importSelectedPhoneContacts(sel) { n -> + status = "已导入 ${sel.size} 人(新增 $n)到「我的人物」" + selected.clear() + } + }, + ) { Text("导入选中($selCount)") } + if (list.isNotEmpty()) { + TextButton(onClick = { + if (selCount == list.size) selected.clear() + else list.forEach { selected[it.id] = true } + }) { Text(if (selCount == list.size) "取消全选" else "全选") } + } + } + status?.let { Text(it, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)) } + HorizontalDivider() + when { + !granted -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("需要通讯录权限才能读取手机联系人。", style = MaterialTheme.typography.bodyMedium) + Button(onClick = { permLauncher.launch(Manifest.permission.READ_CONTACTS) }) { Text("去授权") } + } + } + contacts == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + list.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("手机通讯录里没有带号码的联系人。", style = MaterialTheme.typography.bodyMedium) + } + else -> 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 }) { pc -> + PhoneContactRow(pc, checked = selected[pc.id] == true) { + selected[pc.id] = !(selected[pc.id] ?: false) + } + } + } + } + } + } +} + +private fun phoneLetter(pc: PhoneContact): String { + val c = (pc.pyInitial?.firstOrNull() ?: pc.name.firstOrNull())?.uppercaseChar() + return if (c != null && c in 'A'..'Z') c.toString() else "#" +} + +@Composable +private fun PhoneContactRow(pc: PhoneContact, checked: Boolean, 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 = checked, onCheckedChange = { onToggle() }) + Box(modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + Column(modifier = Modifier.weight(1f)) { + Text(pc.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) + pc.phoneDisplay?.let { Text(it, 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 7b3d45d..d454ab0 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 @@ -109,19 +109,14 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } /** - * 从手机系统通讯录导入联系人到「池子」(managed=false,跟微信一致,用户再勾选)。 - * 跨源去重(用户选择「按手机号/姓名自动合并」): - * 1) 已按 contactRef="phone:" 建过 → 刷新资料; - * 2) 否则手机号命中已有人物 → 只补手机号,不另建; - * 3) 否则姓名精确命中 → 只补手机号,不另建; - * 4) 都没有 → 新建。 - * 调用方需先拿到 READ_CONTACTS。回调返回本轮新建数。 + * 读手机系统通讯录 → 内存列表(不入库),供「手机通讯录」多选页展示。 + * 一个联系人多号码只取第一条;无名字的跳过。调用方需先拿到 READ_CONTACTS。 */ - fun importPhoneContacts(onDone: (Int) -> Unit) { + fun loadPhoneContacts(onResult: (List) -> Unit) { val app = getApplication() viewModelScope.launch { - val created = withContext(Dispatchers.IO) { - var count = 0 + val list = withContext(Dispatchers.IO) { + val out = ArrayList() try { val proj = arrayOf( android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID, @@ -143,17 +138,39 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { if (!seen.add(cid)) continue // 一个联系人多号码,只取第一条 val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)?.trim().orEmpty() if (name.isBlank()) continue - val phone = (if (numIdx >= 0) c.getString(numIdx) else null) - ?.filter { it.isDigit() }?.takeIf { it.isNotBlank() } + val phoneRaw = (if (numIdx >= 0) c.getString(numIdx) else null)?.trim()?.takeIf { it.isNotBlank() } + val phone = phoneRaw?.filter { it.isDigit() }?.takeIf { it.isNotBlank() } val sortKey = if (sortIdx >= 0) c.getString(sortIdx) else null val pinyin = sortKey?.lowercase()?.filter { it.isLetter() }?.takeIf { it.isNotBlank() } val pyInit = ((sortKey?.firstOrNull() ?: name.firstOrNull())?.uppercaseChar()) ?.takeIf { it in 'A'..'Z' }?.toString() - if (upsertPhoneContact(cid, name, phone, pinyin, pyInit)) count++ + out.add(PhoneContact(cid, name, phoneRaw, phone, pinyin, pyInit)) } } } catch (t: Throwable) { - Log.w("PhotologVM", "phone import failed: ${t.message}") + Log.w("PhotologVM", "load phone contacts failed: ${t.message}") + } + out + } + onResult(list) + } + } + + /** + * 把多选页勾选的手机联系人导入「我管理的人物」(managed=true,因为已是人工勾选)。 + * 跨源去重(用户选择「按手机号/姓名自动合并」): + * 1) 已按 contactRef="phone:" 建过 → 刷新资料 + 纳入管理; + * 2) 否则手机号命中已有人物 → 补手机号 + 纳入管理,不另建; + * 3) 否则姓名精确命中 → 补手机号 + 纳入管理,不另建; + * 4) 都没有 → 新建(managed=true)。 + * 回调返回本轮新建数。 + */ + fun importSelectedPhoneContacts(selected: List, onDone: (Int) -> Unit) { + viewModelScope.launch { + val created = withContext(Dispatchers.IO) { + var count = 0 + for (pc in selected) { + if (upsertPhoneContact(pc.id, pc.name, pc.phone, pc.pinyin, pc.pyInitial, managed = true)) count++ } count } @@ -163,20 +180,22 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { /** 返回 true = 本轮新建了一行(用于计数)。 */ private suspend fun upsertPhoneContact( - cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?, + cid: Long, name: String, phone: String?, pinyin: String?, pyInit: String?, managed: Boolean, ): Boolean { val ref = "phone:$cid" repo.persons.getByContactRef(ref)?.let { repo.persons.updatePhoneInfo(it.id, name, phone, pinyin, pyInit) + if (managed) repo.persons.setManaged(it.id, true) return false } val merge = (phone?.let { repo.persons.getByPhone(it) }) ?: repo.persons.getByExactName(name) if (merge != null) { if (merge.phone.isNullOrBlank() && phone != null) repo.persons.setPhone(merge.id, phone) + if (managed) repo.persons.setManaged(merge.id, true) return false } repo.persons.insert( - Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit), + Person(name = name, contactRef = ref, phone = phone, pinyin = pinyin, pyInitial = pyInit, managed = managed), ) return true } @@ -308,6 +327,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } } +/** 手机系统通讯录里的一条(内存态,未入库)。phoneDisplay 留原格式给 UI,phone 是纯数字去重键。 */ +data class PhoneContact( + val id: Long, + val name: String, + val phoneDisplay: String?, + val phone: String?, + val pinyin: String?, + val pyInitial: String?, +) + /** 「录一段绑声纹」对话框的状态机。 */ sealed class VoiceEnroll { object Idle : VoiceEnroll()