Commit a61d5fc857b581c22e85b9b4dfb5636369e7a096
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>
Showing
6 changed files
with
234 additions
and
106 deletions
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<Person>, 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<Person>, 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 | ... | ... |