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 120 }
121 121  
122 122 private enum class Tab(val label: String, val icon: String) {
  123 + People("人物", "👤"), // 通讯录 + 联系人档案(CRM 核心,放第一位)
123 124 Recording("录音", "⏺"), // 顶部子页:录音 / 时间轴
124 125 Chat("对话", "💬"),
125 126 Memo("纪要", "✎"), // 顶部子页:声纹 / 纪要
126   - Photolog("相册", "🖼"),
  127 + Album("相册", "🖼"),
127 128 Settings("设置", "⚙"),
128 129 }
129 130  
... ... @@ -142,7 +143,7 @@ enum class TimeFilter(val label: String) {
142 143  
143 144 @Composable
144 145 private fun MainScreen() {
145   - var tab by remember { mutableStateOf(Tab.Recording) }
  146 + var tab by remember { mutableStateOf(Tab.People) }
146 147 val activity = androidx.compose.ui.platform.LocalContext.current
147 148 val repo = remember(activity) { Repository(activity) }
148 149  
... ... @@ -174,6 +175,7 @@ private fun MainScreen() {
174 175 ) { inner ->
175 176 Box(modifier = Modifier.padding(inner)) {
176 177 when (tab) {
  178 + Tab.People -> ContactsTab(repo)
177 179 Tab.Recording -> RecordingScreen(
178 180 activity = activity, repo = repo, speakers = speakers,
179 181 sub = recordingSub, onSub = { recordingSub = it },
... ... @@ -187,7 +189,7 @@ private fun MainScreen() {
187 189 onFilterBySpeaker = { id -> filterSpeakerId = id; recordingSub = 1; tab = Tab.Recording },
188 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 193 Tab.Settings -> SettingsScreen(activity)
192 194 }
193 195 }
... ... @@ -430,27 +432,41 @@ private fun TimelineTab(
430 432 if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId }
431 433 timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } }
432 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 454 val target = jumpToTs ?: return@LaunchedEffect
443   - if (base.isEmpty()) return@LaunchedEffect
  455 + if (periods.isEmpty()) return@LaunchedEffect
444 456 searchQuery = ""
445 457 sortMode = SortMode.TimeDesc
446 458 timeFilter = TimeFilter.All
447 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 470 onJumpConsumed()
455 471 }
456 472 LaunchedEffect(highlightId) {
... ... @@ -560,21 +576,38 @@ private fun TimelineTab(
560 576 modifier = Modifier.fillMaxWidth().weight(1f),
561 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 1347 selecting: Boolean = false,
1315 1348 selected: Boolean = false,
1316 1349 highlighted: Boolean = false,
  1350 + indent: Boolean = false,
1317 1351 ) {
1318 1352 val time = TIMELINE_FMT.format(Date(row.startTs))
1319   - val durSec = "%.1fs".format(row.durationMs / 1000.0)
1320 1353 val speakerLabel = when {
1321 1354 row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})"
1322 1355 row.speakerName != null -> row.speakerName
... ... @@ -1326,24 +1359,103 @@ private fun TimelineRow(
1326 1359 val playMarker = if (isPlaying) "▶ " else ""
1327 1360 val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
1328 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 1364 horizontalArrangement = Arrangement.spacedBy(8.dp),
1331 1365 ) {
1332 1366 if (selecting) {
1333 1367 Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium)
1334 1368 }
1335 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 1371 Text(
1338   - row.text ?: "(no transcript yet)",
  1372 + row.text ?: "(无文字)",
1339 1373 style = MaterialTheme.typography.bodyMedium,
1340   - maxLines = 3,
  1374 + maxLines = 4,
1341 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 1459 private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
1348 1460  
1349 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 75  
76 76 private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
77 77  
  78 +/** 「人物」一级 tab:通讯录 + 联系人档案页。 */
78 79 @OptIn(ExperimentalMaterial3Api::class)
79 80 @Composable
80   -internal fun PhotologTab(repo: Repository) {
  81 +internal fun ContactsTab(repo: Repository) {
81 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 83 var profilePersonId by remember { mutableStateOf<Long?>(null) }
86   -
  84 + var detailPhotoId by remember { mutableStateOf<Long?>(null) }
87 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 87 val pid = profilePersonId
101 88 if (pid != null) {
... ... @@ -104,65 +91,60 @@ internal fun PhotologTab(repo: Repository) {
104 91 onBack = { profilePersonId = null },
105 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 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 148 detailPhotoId?.let { id ->
167 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 331 .groupBy { sectionLetter(it) }
350 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 340 Column(modifier = Modifier.fillMaxSize()) {
354 341 OutlinedTextField(
... ... @@ -375,6 +362,14 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
375 362 }
376 363 } else {
377 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 373 sections.forEach { (letter, group) ->
379 374 stickyHeader {
380 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 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 84 /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */
80 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 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 22 WechatMessage::class,
23 23 Moment::class,
24 24 ],
25   - version = 9,
  25 + version = 10,
26 26 exportSchema = false,
27 27 )
28 28 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 113 fun get(ctx: Context): AppDatabase =
107 114 instance ?: synchronized(this) {
108 115 instance ?: Room.databaseBuilder(
... ... @@ -110,7 +117,7 @@ abstract class AppDatabase : RoomDatabase() {
110 117 AppDatabase::class.java,
111 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 121 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
115 122 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
116 123 .build()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -269,6 +269,10 @@ interface PersonDao {
269 269 @Query("UPDATE persons SET impression = :text WHERE id = :id")
270 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 276 @Query("UPDATE persons SET name = :name WHERE id = :id")
273 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 106 val phone: String? = null, // 手机号(短信关联)
107 107 /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */
108 108 val impression: String? = null,
  109 + /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */
  110 + val lastAccess: Long = 0,
109 111 val createdAt: Long = System.currentTimeMillis(),
110 112 )
111 113  
... ...