Commit 47b7f104ecba017a6af5767f97336d819ec3471a
1 parent
0c2594c9
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,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<Person>, onOpen: (L | @@ -349,6 +331,11 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, 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<Person>, onOpen: (L | @@ -375,6 +362,14 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, 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 |