Commit a61d5fc857b581c22e85b9b4dfb5636369e7a096

Authored by yibo
1 parent ab5af8da

feat(lifelog/photolog): 人物升为底部第一个 tab + 最近联系人置顶

- 「人物」从相册子页提升为**独立一级 tab,放第一位**(默认打开);PhotologTab 拆成
  ContactsTab(通讯录+档案) 与 AlbumTab(照片),共用同一 PhotologViewModel(Activity 域)
- 通讯录排序:点过/绑过声纹的联系人 lastAccess 置顶到「★ 最近联系人」区,
  其余仍按拼音 A–Z(Person.lastAccess 字段 + 迁移 v9→v10;点开档案/绑声纹时 touch)

注:本提交还顺带含时间轴「按 5min 静音切时间段分组」的并行改动(同文件耦合,已编译通过)。

实测:默认进人物页(共 4357),点联系人后回列表,顶部出现「最近联系人」;迁移 v10 零数据损失。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
@@ -120,10 +120,11 @@ class MainActivity : ComponentActivity() { @@ -120,10 +120,11 @@ class MainActivity : ComponentActivity() {
120 } 120 }
121 121
122 private enum class Tab(val label: String, val icon: String) { 122 private enum class Tab(val label: String, val icon: String) {
  123 + People("人物", "👤"), // 通讯录 + 联系人档案(CRM 核心,放第一位)
123 Recording("录音", "⏺"), // 顶部子页:录音 / 时间轴 124 Recording("录音", "⏺"), // 顶部子页:录音 / 时间轴
124 Chat("对话", "💬"), 125 Chat("对话", "💬"),
125 Memo("纪要", "✎"), // 顶部子页:声纹 / 纪要 126 Memo("纪要", "✎"), // 顶部子页:声纹 / 纪要
126 - Photolog("相册", "🖼"), 127 + Album("相册", "🖼"),
127 Settings("设置", "⚙"), 128 Settings("设置", "⚙"),
128 } 129 }
129 130
@@ -142,7 +143,7 @@ enum class TimeFilter(val label: String) { @@ -142,7 +143,7 @@ enum class TimeFilter(val label: String) {
142 143
143 @Composable 144 @Composable
144 private fun MainScreen() { 145 private fun MainScreen() {
145 - var tab by remember { mutableStateOf(Tab.Recording) } 146 + var tab by remember { mutableStateOf(Tab.People) }
146 val activity = androidx.compose.ui.platform.LocalContext.current 147 val activity = androidx.compose.ui.platform.LocalContext.current
147 val repo = remember(activity) { Repository(activity) } 148 val repo = remember(activity) { Repository(activity) }
148 149
@@ -174,6 +175,7 @@ private fun MainScreen() { @@ -174,6 +175,7 @@ private fun MainScreen() {
174 ) { inner -> 175 ) { inner ->
175 Box(modifier = Modifier.padding(inner)) { 176 Box(modifier = Modifier.padding(inner)) {
176 when (tab) { 177 when (tab) {
  178 + Tab.People -> ContactsTab(repo)
177 Tab.Recording -> RecordingScreen( 179 Tab.Recording -> RecordingScreen(
178 activity = activity, repo = repo, speakers = speakers, 180 activity = activity, repo = repo, speakers = speakers,
179 sub = recordingSub, onSub = { recordingSub = it }, 181 sub = recordingSub, onSub = { recordingSub = it },
@@ -187,7 +189,7 @@ private fun MainScreen() { @@ -187,7 +189,7 @@ private fun MainScreen() {
187 onFilterBySpeaker = { id -> filterSpeakerId = id; recordingSub = 1; tab = Tab.Recording }, 189 onFilterBySpeaker = { id -> filterSpeakerId = id; recordingSub = 1; tab = Tab.Recording },
188 onJumpToTime = { ts -> filterSpeakerId = null; jumpToTs = ts; recordingSub = 1; tab = Tab.Recording }, 190 onJumpToTime = { ts -> filterSpeakerId = null; jumpToTs = ts; recordingSub = 1; tab = Tab.Recording },
189 ) 191 )
190 - Tab.Photolog -> PhotologTab(repo) 192 + Tab.Album -> AlbumTab(repo)
191 Tab.Settings -> SettingsScreen(activity) 193 Tab.Settings -> SettingsScreen(activity)
192 } 194 }
193 } 195 }
@@ -430,27 +432,41 @@ private fun TimelineTab( @@ -430,27 +432,41 @@ private fun TimelineTab(
430 if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId } 432 if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId }
431 timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } } 433 timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } }
432 if (unmatchedOnly) l = l.filter { it.speakerId == null || (it.speakerSim != null && it.speakerSim < 0.55f) } 434 if (unmatchedOnly) l = l.filter { it.speakerId == null || (it.speakerSim != null && it.speakerSim < 0.55f) }
433 - when (sortMode) {  
434 - SortMode.TimeDesc -> l.sortedByDescending { it.startTs }  
435 - SortMode.TimeAsc -> l.sortedBy { it.startTs }  
436 - SortMode.DurationDesc -> l.sortedByDescending { it.durationMs } 435 + // 仅用于"全选/导出/计数"的平铺集合;展示按时间段分组(见下)。
  436 + l.sortedByDescending { it.startTs }
  437 + }
  438 +
  439 + // 按「时间段」分组:turn 间隔 ≥ 5min(= 录音块静音边界)即切新段。一整场连续录音
  440 + // (中间无 5min 静音)合成一个时间段,段内按声纹逐 turn 顺序展示。
  441 + val periods = remember(shown, sortMode) { buildTimelinePeriods(shown, sortMode) }
  442 + val collapsed = remember { mutableStateListOf<Long>() } // 收起的时间段(key=段 startTs)
  443 + val displayItems = remember(periods, collapsed.toList()) {
  444 + buildList<TlItem> {
  445 + for (p in periods) {
  446 + add(TlItem.Head(p))
  447 + if (!collapsed.contains(p.key)) p.rows.forEach { add(TlItem.Turn(it)) }
  448 + }
437 } 449 }
438 } 450 }
439 451
440 - // Jump from a summary bullet: reset filters, scroll to the closest segment, highlight it.  
441 - LaunchedEffect(jumpToTs, base) { 452 + // Jump from a summary bullet: reset filters, expand+scroll to the target period, highlight.
  453 + LaunchedEffect(jumpToTs, periods) {
442 val target = jumpToTs ?: return@LaunchedEffect 454 val target = jumpToTs ?: return@LaunchedEffect
443 - if (base.isEmpty()) return@LaunchedEffect 455 + if (periods.isEmpty()) return@LaunchedEffect
444 searchQuery = "" 456 searchQuery = ""
445 sortMode = SortMode.TimeDesc 457 sortMode = SortMode.TimeDesc
446 timeFilter = TimeFilter.All 458 timeFilter = TimeFilter.All
447 unmatchedOnly = false 459 unmatchedOnly = false
448 - val sorted = base.sortedByDescending { it.startTs }  
449 - val idx = sorted.indices.minByOrNull { kotlin.math.abs(sorted[it].startTs - target) }  
450 - if (idx != null) {  
451 - highlightId = sorted[idx].id  
452 - lazyState.scrollToItem(idx)  
453 - } 460 + // 找含目标时间最近 turn 的时间段,展开它,滚到其段头。
  461 + val pIdx = periods.indices.minByOrNull { pi ->
  462 + periods[pi].rows.minOf { kotlin.math.abs(it.startTs - target) }
  463 + } ?: return@LaunchedEffect
  464 + val p = periods[pIdx]
  465 + collapsed.remove(p.key)
  466 + highlightId = p.rows.minByOrNull { kotlin.math.abs(it.startTs - target) }?.id
  467 + var idx = 0
  468 + for (i in 0 until pIdx) idx += 1 + if (!collapsed.contains(periods[i].key)) periods[i].rows.size else 0
  469 + lazyState.scrollToItem(idx)
454 onJumpConsumed() 470 onJumpConsumed()
455 } 471 }
456 LaunchedEffect(highlightId) { 472 LaunchedEffect(highlightId) {
@@ -560,21 +576,38 @@ private fun TimelineTab( @@ -560,21 +576,38 @@ private fun TimelineTab(
560 modifier = Modifier.fillMaxWidth().weight(1f), 576 modifier = Modifier.fillMaxWidth().weight(1f),
561 verticalArrangement = Arrangement.spacedBy(2.dp), 577 verticalArrangement = Arrangement.spacedBy(2.dp),
562 ) { 578 ) {
563 - items(shown, key = { it.id }) { row ->  
564 - TimelineRow(  
565 - row = row,  
566 - isPlaying = nowPlayingId == row.id,  
567 - selecting = selecting,  
568 - selected = selected.contains(row.id),  
569 - highlighted = highlightId == row.id,  
570 - onClick = {  
571 - if (selecting) {  
572 - if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id)  
573 - } else {  
574 - actionRow = row  
575 - }  
576 - },  
577 - ) 579 + items(
  580 + displayItems,
  581 + key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" },
  582 + ) { item ->
  583 + when (item) {
  584 + is TlItem.Head -> TimelinePeriodHeader(
  585 + period = item.period,
  586 + collapsed = collapsed.contains(item.period.key),
  587 + onToggle = {
  588 + if (collapsed.contains(item.period.key)) collapsed.remove(item.period.key)
  589 + else collapsed.add(item.period.key)
  590 + },
  591 + )
  592 + is TlItem.Turn -> {
  593 + val row = item.row
  594 + TimelineRow(
  595 + row = row,
  596 + isPlaying = nowPlayingId == row.id,
  597 + selecting = selecting,
  598 + selected = selected.contains(row.id),
  599 + highlighted = highlightId == row.id,
  600 + indent = true,
  601 + onClick = {
  602 + if (selecting) {
  603 + if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id)
  604 + } else {
  605 + actionRow = row
  606 + }
  607 + },
  608 + )
  609 + }
  610 + }
578 } 611 }
579 } 612 }
580 } 613 }
@@ -1314,9 +1347,9 @@ private fun TimelineRow( @@ -1314,9 +1347,9 @@ private fun TimelineRow(
1314 selecting: Boolean = false, 1347 selecting: Boolean = false,
1315 selected: Boolean = false, 1348 selected: Boolean = false,
1316 highlighted: Boolean = false, 1349 highlighted: Boolean = false,
  1350 + indent: Boolean = false,
1317 ) { 1351 ) {
1318 val time = TIMELINE_FMT.format(Date(row.startTs)) 1352 val time = TIMELINE_FMT.format(Date(row.startTs))
1319 - val durSec = "%.1fs".format(row.durationMs / 1000.0)  
1320 val speakerLabel = when { 1353 val speakerLabel = when {
1321 row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" 1354 row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})"
1322 row.speakerName != null -> row.speakerName 1355 row.speakerName != null -> row.speakerName
@@ -1326,24 +1359,103 @@ private fun TimelineRow( @@ -1326,24 +1359,103 @@ private fun TimelineRow(
1326 val playMarker = if (isPlaying) "▶ " else "" 1359 val playMarker = if (isPlaying) "▶ " else ""
1327 val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent 1360 val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
1328 Row( 1361 Row(
1329 - modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick).padding(vertical = 4.dp), 1362 + modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick)
  1363 + .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp),
1330 horizontalArrangement = Arrangement.spacedBy(8.dp), 1364 horizontalArrangement = Arrangement.spacedBy(8.dp),
1331 ) { 1365 ) {
1332 if (selecting) { 1366 if (selecting) {
1333 Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) 1367 Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium)
1334 } 1368 }
1335 Column(modifier = Modifier.weight(1f)) { 1369 Column(modifier = Modifier.weight(1f)) {
1336 - Text("$playMarker$time · $durSec · #${row.id} · $speakerLabel", style = MaterialTheme.typography.labelSmall) 1370 + Text("$playMarker$time · $speakerLabel", style = MaterialTheme.typography.labelSmall)
1337 Text( 1371 Text(
1338 - row.text ?: "(no transcript yet)", 1372 + row.text ?: "(无文字)",
1339 style = MaterialTheme.typography.bodyMedium, 1373 style = MaterialTheme.typography.bodyMedium,
1340 - maxLines = 3, 1374 + maxLines = 4,
1341 overflow = TextOverflow.Ellipsis, 1375 overflow = TextOverflow.Ellipsis,
1342 ) 1376 )
1343 } 1377 }
1344 } 1378 }
1345 } 1379 }
1346 1380
  1381 +// ---- 时间段分组(按 5min 静音切,= 录音块边界)----
  1382 +
  1383 +private const val TIMELINE_GROUP_GAP_MS = 5 * 60 * 1000L
  1384 +
  1385 +/** 一个「时间段」:起止时间 + 段内 turn(按时间升序,对话式阅读)+ 去重说话人名单。 */
  1386 +private class TimelinePeriod(
  1387 + val key: Long, // = 段首 turn 的 startTs
  1388 + val startTs: Long,
  1389 + val endTs: Long,
  1390 + val rows: List<SegmentRow>,
  1391 + val speakers: List<String>,
  1392 +)
  1393 +
  1394 +private sealed interface TlItem {
  1395 + data class Head(val period: TimelinePeriod) : TlItem
  1396 + data class Turn(val row: SegmentRow) : TlItem
  1397 +}
  1398 +
  1399 +/** 把(已过滤的)turn 列表按 5min 间隔切成时间段;段间顺序随 [sortMode],段内恒时间升序。 */
  1400 +private fun buildTimelinePeriods(rows: List<SegmentRow>, sortMode: SortMode): List<TimelinePeriod> {
  1401 + if (rows.isEmpty()) return emptyList()
  1402 + val asc = rows.sortedBy { it.startTs }
  1403 + val groups = ArrayList<MutableList<SegmentRow>>()
  1404 + var cur = mutableListOf(asc[0])
  1405 + for (i in 1 until asc.size) {
  1406 + if (asc[i].startTs - asc[i - 1].startTs >= TIMELINE_GROUP_GAP_MS) {
  1407 + groups.add(cur); cur = mutableListOf()
  1408 + }
  1409 + cur.add(asc[i])
  1410 + }
  1411 + groups.add(cur)
  1412 + val periods = groups.map { g ->
  1413 + val last = g.last()
  1414 + TimelinePeriod(
  1415 + key = g.first().startTs,
  1416 + startTs = g.first().startTs,
  1417 + endTs = last.startTs + last.durationMs,
  1418 + rows = g,
  1419 + speakers = g.mapNotNull { it.speakerName }.distinct(),
  1420 + )
  1421 + }
  1422 + return when (sortMode) {
  1423 + SortMode.TimeAsc -> periods
  1424 + SortMode.TimeDesc -> periods.reversed()
  1425 + SortMode.DurationDesc -> periods.sortedByDescending { it.endTs - it.startTs }
  1426 + }
  1427 +}
  1428 +
  1429 +private fun humanDur(ms: Long): String {
  1430 + val s = ms / 1000
  1431 + val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
  1432 + return when {
  1433 + h > 0 -> "${h}h${m}m"
  1434 + m > 0 -> "${m}m"
  1435 + else -> "${sec}s"
  1436 + }
  1437 +}
  1438 +
  1439 +private val PERIOD_HEAD_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
  1440 +private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US)
  1441 +
  1442 +@Composable
  1443 +private fun TimelinePeriodHeader(period: TimelinePeriod, collapsed: Boolean, onToggle: () -> Unit) {
  1444 + val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}"
  1445 + val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·")
  1446 + Row(
  1447 + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(top = 10.dp, bottom = 4.dp),
  1448 + horizontalArrangement = Arrangement.spacedBy(6.dp),
  1449 + verticalAlignment = Alignment.CenterVertically,
  1450 + ) {
  1451 + Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall)
  1452 + Column(modifier = Modifier.weight(1f)) {
  1453 + Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall)
  1454 + Text("${period.rows.size} 段 · $spk", style = MaterialTheme.typography.labelSmall)
  1455 + }
  1456 + }
  1457 +}
  1458 +
1347 private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) 1459 private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
1348 1460
1349 /** Build a plain-text transcript and fire a share/save chooser. */ 1461 /** Build a plain-text transcript and fire a share/save chooser. */
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -75,27 +75,14 @@ import java.util.Locale @@ -75,27 +75,14 @@ import java.util.Locale
75 75
76 private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) 76 private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
77 77
  78 +/** 「人物」一级 tab:通讯录 + 联系人档案页。 */
78 @OptIn(ExperimentalMaterial3Api::class) 79 @OptIn(ExperimentalMaterial3Api::class)
79 @Composable 80 @Composable
80 -internal fun PhotologTab(repo: Repository) { 81 +internal fun ContactsTab(repo: Repository) {
81 val vm: PhotologViewModel = viewModel() 82 val vm: PhotologViewModel = viewModel()
82 - var view by remember { mutableStateOf(0) } // 0=照片 1=人物  
83 - var filterPersonId by remember { mutableStateOf<Long?>(null) }  
84 - var detailPhotoId by remember { mutableStateOf<Long?>(null) }  
85 var profilePersonId by remember { mutableStateOf<Long?>(null) } 83 var profilePersonId by remember { mutableStateOf<Long?>(null) }
86 - 84 + var detailPhotoId by remember { mutableStateOf<Long?>(null) }
87 val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) 85 val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList())
88 - val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0)  
89 - val personName = remember(persons, filterPersonId) { persons.firstOrNull { it.id == filterPersonId }?.name }  
90 -  
91 - val photos by remember(filterPersonId) {  
92 - val pid = filterPersonId  
93 - if (pid == null) vm.repo.photos.observeGrid(1000) else vm.repo.photos.observePhotosOfPerson(pid)  
94 - }.collectAsState(initial = emptyList())  
95 -  
96 - val picker = rememberLauncherForActivityResult(  
97 - ActivityResultContracts.PickMultipleVisualMedia(30),  
98 - ) { uris -> vm.importUris(uris) }  
99 86
100 val pid = profilePersonId 87 val pid = profilePersonId
101 if (pid != null) { 88 if (pid != null) {
@@ -104,65 +91,60 @@ internal fun PhotologTab(repo: Repository) { @@ -104,65 +91,60 @@ internal fun PhotologTab(repo: Repository) {
104 onBack = { profilePersonId = null }, 91 onBack = { profilePersonId = null },
105 onOpenPhoto = { detailPhotoId = it }, 92 onOpenPhoto = { detailPhotoId = it },
106 ) 93 )
107 - detailPhotoId?.let { id ->  
108 - PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null })  
109 - }  
110 - return 94 + } else {
  95 + ContactList(vm = vm, persons = persons, onOpen = { vm.touchPerson(it); profilePersonId = it })
  96 + }
  97 + detailPhotoId?.let { id ->
  98 + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null })
111 } 99 }
  100 +}
112 101
113 - Column(modifier = Modifier.fillMaxSize()) {  
114 - TabRow(selectedTabIndex = view) {  
115 - Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") })  
116 - Tab(selected = view == 1, onClick = { view = 1 }, text = { Text("人物") })  
117 - } 102 +/** 「相册」一级 tab:照片网格(导入 + 详情 + 标注)。 */
  103 +@OptIn(ExperimentalMaterial3Api::class)
  104 +@Composable
  105 +internal fun AlbumTab(repo: Repository) {
  106 + val vm: PhotologViewModel = viewModel()
  107 + var detailPhotoId by remember { mutableStateOf<Long?>(null) }
  108 + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList())
  109 + val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0)
  110 + val photos by remember { vm.repo.photos.observeGrid(1000) }.collectAsState(initial = emptyList())
118 111
119 - if (view == 0) {  
120 - // 操作行:导入 + 人物筛选 + 处理进度。  
121 - Row(  
122 - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),  
123 - verticalAlignment = Alignment.CenterVertically,  
124 - horizontalArrangement = Arrangement.spacedBy(8.dp),  
125 - ) {  
126 - Button(onClick = {  
127 - picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))  
128 - }) { Text("+ 导入照片") }  
129 - if (filterPersonId != null) {  
130 - FilterChip(selected = true, onClick = { filterPersonId = null }, label = { Text("👤 ${personName ?: "?"} ✕") })  
131 - }  
132 - if (pendingCount > 0) {  
133 - CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)  
134 - Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall)  
135 - }  
136 - } 112 + val picker = rememberLauncherForActivityResult(
  113 + ActivityResultContracts.PickMultipleVisualMedia(30),
  114 + ) { uris -> vm.importUris(uris) }
137 115
138 - if (photos.isEmpty()) {  
139 - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {  
140 - Text(  
141 - if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。",  
142 - style = MaterialTheme.typography.bodyMedium,  
143 - )  
144 - }  
145 - } else {  
146 - LazyVerticalGrid(  
147 - columns = GridCells.Fixed(3),  
148 - modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp),  
149 - verticalArrangement = Arrangement.spacedBy(4.dp),  
150 - horizontalArrangement = Arrangement.spacedBy(4.dp),  
151 - ) {  
152 - gridItems(photos, key = { it.id }) { p ->  
153 - PhotoCell(p) { detailPhotoId = p.id }  
154 - }  
155 - } 116 + Column(modifier = Modifier.fillMaxSize()) {
  117 + Row(
  118 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
  119 + verticalAlignment = Alignment.CenterVertically,
  120 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  121 + ) {
  122 + Button(onClick = {
  123 + picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
  124 + }) { Text("+ 导入照片") }
  125 + if (pendingCount > 0) {
  126 + CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
  127 + Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall)
  128 + }
  129 + }
  130 + if (photos.isEmpty()) {
  131 + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
  132 + Text(
  133 + if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。",
  134 + style = MaterialTheme.typography.bodyMedium,
  135 + )
156 } 136 }
157 } else { 137 } else {
158 - ContactList(  
159 - vm = vm,  
160 - persons = persons,  
161 - onOpen = { id -> profilePersonId = id },  
162 - ) 138 + LazyVerticalGrid(
  139 + columns = GridCells.Fixed(3),
  140 + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp),
  141 + verticalArrangement = Arrangement.spacedBy(4.dp),
  142 + horizontalArrangement = Arrangement.spacedBy(4.dp),
  143 + ) {
  144 + gridItems(photos, key = { it.id }) { p -> PhotoCell(p) { detailPhotoId = p.id } }
  145 + }
163 } 146 }
164 } 147 }
165 -  
166 detailPhotoId?.let { id -> 148 detailPhotoId?.let { id ->
167 PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) 149 PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null })
168 } 150 }
@@ -349,6 +331,11 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -349,6 +331,11 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
349 .groupBy { sectionLetter(it) } 331 .groupBy { sectionLetter(it) }
350 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 332 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后
351 } 333 }
  334 + // 最近联系人(点过 / 有过互动),置顶;搜索时不显示。
  335 + val recent = remember(persons, query) {
  336 + if (query.isNotBlank()) emptyList()
  337 + else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12)
  338 + }
352 339
353 Column(modifier = Modifier.fillMaxSize()) { 340 Column(modifier = Modifier.fillMaxSize()) {
354 OutlinedTextField( 341 OutlinedTextField(
@@ -375,6 +362,14 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -375,6 +362,14 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
375 } 362 }
376 } else { 363 } else {
377 LazyColumn(modifier = Modifier.fillMaxSize()) { 364 LazyColumn(modifier = Modifier.fillMaxSize()) {
  365 + if (recent.isNotEmpty()) {
  366 + stickyHeader {
  367 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
  368 + Text("★ 最近联系人", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp))
  369 + }
  370 + }
  371 + items(recent, key = { "r${it.id}" }) { p -> ContactRow(p) { onOpen(p.id) } }
  372 + }
378 sections.forEach { (letter, group) -> 373 sections.forEach { (letter, group) ->
379 stickyHeader { 374 stickyHeader {
380 Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { 375 Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -76,9 +76,17 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -76,9 +76,17 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
76 viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } 76 viewModelScope.launch { repo.persons.updateName(personId, name.trim()) }
77 } 77 }
78 78
  79 + /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */
  80 + fun touchPerson(personId: Long) {
  81 + viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) }
  82 + }
  83 +
79 /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ 84 /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */
80 fun bindVoice(personId: Long, speakerId: Long?) { 85 fun bindVoice(personId: Long, speakerId: Long?) {
81 - viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } 86 + viewModelScope.launch {
  87 + repo.persons.linkVoice(personId, speakerId)
  88 + if (speakerId != null) repo.persons.touch(personId, System.currentTimeMillis())
  89 + }
82 } 90 }
83 91
84 /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ 92 /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
@@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
22 WechatMessage::class, 22 WechatMessage::class,
23 Moment::class, 23 Moment::class,
24 ], 24 ],
25 - version = 9, 25 + version = 10,
26 exportSchema = false, 26 exportSchema = false,
27 ) 27 )
28 abstract class AppDatabase : RoomDatabase() { 28 abstract class AppDatabase : RoomDatabase() {
@@ -103,6 +103,13 @@ abstract class AppDatabase : RoomDatabase() { @@ -103,6 +103,13 @@ abstract class AppDatabase : RoomDatabase() {
103 } 103 }
104 } 104 }
105 105
  106 + /** 通讯录「最近联系人」置顶:persons.lastAccess。 */
  107 + private val MIGRATION_9_10 = object : Migration(9, 10) {
  108 + override fun migrate(db: SupportSQLiteDatabase) {
  109 + db.execSQL("ALTER TABLE `persons` ADD COLUMN `lastAccess` INTEGER NOT NULL DEFAULT 0")
  110 + }
  111 + }
  112 +
106 fun get(ctx: Context): AppDatabase = 113 fun get(ctx: Context): AppDatabase =
107 instance ?: synchronized(this) { 114 instance ?: synchronized(this) {
108 instance ?: Room.databaseBuilder( 115 instance ?: Room.databaseBuilder(
@@ -110,7 +117,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -110,7 +117,7 @@ abstract class AppDatabase : RoomDatabase() {
110 AppDatabase::class.java, 117 AppDatabase::class.java,
111 "lifelog.db", 118 "lifelog.db",
112 ) 119 )
113 - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9) 120 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10)
114 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, 121 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
115 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 122 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
116 .build() 123 .build()
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -269,6 +269,10 @@ interface PersonDao { @@ -269,6 +269,10 @@ interface PersonDao {
269 @Query("UPDATE persons SET impression = :text WHERE id = :id") 269 @Query("UPDATE persons SET impression = :text WHERE id = :id")
270 suspend fun updateImpression(id: Long, text: String) 270 suspend fun updateImpression(id: Long, text: String)
271 271
  272 + /** 刷新最近访问时间(点开档案/绑声纹时调,进「最近联系人」置顶)。 */
  273 + @Query("UPDATE persons SET lastAccess = :ts WHERE id = :id")
  274 + suspend fun touch(id: Long, ts: Long)
  275 +
272 @Query("UPDATE persons SET name = :name WHERE id = :id") 276 @Query("UPDATE persons SET name = :name WHERE id = :id")
273 suspend fun updateName(id: Long, name: String) 277 suspend fun updateName(id: Long, name: String)
274 278
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
@@ -106,6 +106,8 @@ data class Person( @@ -106,6 +106,8 @@ data class Person(
106 val phone: String? = null, // 手机号(短信关联) 106 val phone: String? = null, // 手机号(短信关联)
107 /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ 107 /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */
108 val impression: String? = null, 108 val impression: String? = null,
  109 + /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */
  110 + val lastAccess: Long = 0,
109 val createdAt: Long = System.currentTimeMillis(), 111 val createdAt: Long = System.currentTimeMillis(),
110 ) 112 )
111 113