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 | 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: () -> 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: () -> 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: () -> 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: () -> 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() | ... | ... |