diff --git a/lifelog-recorder/app/src/main/AndroidManifest.xml b/lifelog-recorder/app/src/main/AndroidManifest.xml index 8f8dbae..71b00cb 100644 --- a/lifelog-recorder/app/src/main/AndroidManifest.xml +++ b/lifelog-recorder/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 42dfd38..dfe7391 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -122,8 +122,9 @@ class MainActivity : ComponentActivity() { } private enum class Tab(val label: String, val icon: String) { - Chat("对话", "💬"), // 默认首页:AI 对话 + 工具模式 + Chat("对话", "💬"), People("人物", "👤"), + Timeline("时间轴", "🕐"), // Life OS 核心:跨源统一时间轴 Recording("录音", "⏺"), Album("相册", "🖼"), Settings("设置", "⚙"), @@ -175,6 +176,7 @@ private fun MainScreen() { ) { inner -> Box(modifier = Modifier.padding(inner)) { when (tab) { + Tab.Timeline -> TimelineScreen() Tab.People -> ContactsTab(repo) Tab.Recording -> RecordingScreen( activity = activity, repo = repo, speakers = speakers, diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TimelineScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TimelineScreen.kt new file mode 100644 index 0000000..6d8e081 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TimelineScreen.kt @@ -0,0 +1,377 @@ +package com.lemon.lifelog + +import android.app.Application +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.lemon.lifelog.data.Moment +import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.PhotoRow +import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.SegmentRow +import com.lemon.lifelog.data.WechatMessage +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// ─── 数据模型 ─────────────────────────────────────────────────────────────── + +sealed class TimelineItem { + abstract val ts: Long + + data class Voice(val segId: Long, override val ts: Long, + val personName: String?, val text: String) : TimelineItem() + + data class WeChat(val id: Long, override val ts: Long, + val personName: String?, val content: String, + val isSend: Boolean) : TimelineItem() + + data class Moment(val id: Long, override val ts: Long, + val personName: String?, val text: String) : TimelineItem() + + data class Photo(val id: Long, override val ts: Long, + val path: String, val caption: String?, + val faceCount: Int) : TimelineItem() +} + +enum class TlSource(val label: String, val icon: String) { + VOICE("录音", "🎙"), + WECHAT("微信", "💬"), + MOMENT("朋友圈", "👥"), + PHOTO("照片", "📷"), +} + +// ─── ViewModel ────────────────────────────────────────────────────────────── + +class TimelineViewModel(app: Application) : AndroidViewModel(app) { + private val repo = Repository(app) + + val items = combine( + repo.segments.observeTimeline(300), + repo.wechatMessages.observeRecent(300), + repo.moments.observeRecent(300), + repo.photos.observeGrid(300), + repo.persons.observeAll(), + ) { segs: List, msgs: List, moments: List, + photos: List, persons: List -> + + val wxMap = persons.mapNotNull { p -> + val ref = p.contactRef ?: return@mapNotNull null + if (!ref.startsWith("wechat:")) return@mapNotNull null + ref.removePrefix("wechat:") to p.name + }.toMap() + + buildList { + segs.forEach { s -> + val t = s.text?.takeIf { it.isNotBlank() } ?: return@forEach + add(TimelineItem.Voice(s.id, s.startTs, s.speakerName ?: s.personName, t)) + } + msgs.forEach { m -> + if (m.content.isBlank()) return@forEach + add(TimelineItem.WeChat(m.id, m.createTime, wxMap[m.talker], m.content, m.isSend)) + } + moments.forEach { m -> + if (m.text.isBlank()) return@forEach + add(TimelineItem.Moment(m.id, m.createTime, wxMap[m.userName] ?: m.userName, m.text)) + } + photos.filter { it.scanState >= 1 || it.faceCount > 0 || !it.caption.isNullOrBlank() } + .forEach { p -> + add(TimelineItem.Photo(p.id, p.takenTs, p.path, p.caption, p.faceCount)) + } + }.sortedByDescending { it.ts } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) +} + +// ─── 辅助:带日期分隔头的扁平列表 ───────────────────────────────────────── + +private sealed class TlEntry { + data class Header(val label: String) : TlEntry() + data class Event(val item: TimelineItem) : TlEntry() +} + +private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) +private val lblFmt = SimpleDateFormat("M月d日", Locale.CHINESE) +private val timeFmt = SimpleDateFormat("HH:mm", Locale.getDefault()) + +private fun buildEntries(items: List): List { + val result = mutableListOf() + var lastDay = "" + val today = dayFmt.format(System.currentTimeMillis()) + val yesterday = dayFmt.format(System.currentTimeMillis() - 86_400_000L) + for (item in items) { + val day = dayFmt.format(Date(item.ts)) + if (day != lastDay) { + result += TlEntry.Header(when (day) { + today -> "今天" + yesterday -> "昨天" + else -> lblFmt.format(Date(item.ts)) + }) + lastDay = day + } + result += TlEntry.Event(item) + } + return result +} + +// ─── Screen ───────────────────────────────────────────────────────────────── + +@Composable +fun TimelineScreen() { + val vm: TimelineViewModel = viewModel() + val allItems by vm.items.collectAsState() + + var activeFilters by remember { mutableStateOf(TlSource.entries.toSet()) } + + val filtered = remember(allItems, activeFilters) { + allItems.filter { item -> + when (item) { + is TimelineItem.Voice -> TlSource.VOICE in activeFilters + is TimelineItem.WeChat -> TlSource.WECHAT in activeFilters + is TimelineItem.Moment -> TlSource.MOMENT in activeFilters + is TimelineItem.Photo -> TlSource.PHOTO in activeFilters + } + } + } + + val entries = remember(filtered) { buildEntries(filtered) } + + Column(Modifier.fillMaxSize()) { + // 过滤 chips + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val allOn = activeFilters.size == TlSource.entries.size + FilterChip( + selected = allOn, + onClick = { + activeFilters = + if (allOn) emptySet() else TlSource.entries.toSet() + }, + label = { Text("全部") }, + ) + TlSource.entries.forEach { src -> + FilterChip( + selected = src in activeFilters, + onClick = { + activeFilters = + if (src in activeFilters) activeFilters - src + else activeFilters + src + }, + label = { Text("${src.icon} ${src.label}") }, + ) + } + } + + if (entries.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items( + entries, + key = { e -> + when (e) { + is TlEntry.Header -> "h_${e.label}" + is TlEntry.Event -> when (val i = e.item) { + is TimelineItem.Voice -> "v_${i.segId}" + is TimelineItem.WeChat -> "w_${i.id}" + is TimelineItem.Moment -> "mo_${i.id}" + is TimelineItem.Photo -> "p_${i.id}" + } + } + }, + ) { entry -> + when (entry) { + is TlEntry.Header -> TlDateHeader(entry.label) + is TlEntry.Event -> TlEventRow(entry.item) + } + } + } + } + } +} + +// ─── 子组件 ────────────────────────────────────────────────────────────────── + +@Composable +private fun TlDateHeader(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp, bottom = 4.dp, start = 4.dp), + ) +} + +private data class SrcStyle(val icon: String, val color: Color) + +private fun srcStyle(item: TimelineItem) = when (item) { + is TimelineItem.Voice -> SrcStyle("🎙", Color(0xFF7C4DFF)) + is TimelineItem.WeChat -> SrcStyle("💬", Color(0xFF43A047)) + is TimelineItem.Moment -> SrcStyle("👥", Color(0xFF1E88E5)) + is TimelineItem.Photo -> SrcStyle("📷", Color(0xFFFF9800)) +} + +@Composable +private fun TlEventRow(item: TimelineItem) { + val (icon, color) = srcStyle(item) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + // 左侧:图标 + 时间 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(44.dp).padding(top = 2.dp), + ) { + Box( + Modifier + .size(28.dp) + .background(color.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(icon, fontSize = 14.sp) + } + Text( + timeFmt.format(Date(item.ts)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + + // 右侧:内容卡片 + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + // 人名标签 + val name: String? = when (item) { + is TimelineItem.Voice -> item.personName + is TimelineItem.WeChat -> if (item.isSend) "我" else item.personName + is TimelineItem.Moment -> item.personName + is TimelineItem.Photo -> null + } + if (name != null) { + Text( + name, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 2.dp), + ) + } + + // 正文 + when (item) { + is TimelineItem.Voice -> Text( + item.text, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + is TimelineItem.WeChat -> Text( + item.content, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + is TimelineItem.Moment -> Text( + item.text, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + is TimelineItem.Photo -> { + val ctx = LocalContext.current + AsyncImage( + model = ImageRequest.Builder(ctx) + .data( + if (item.path.startsWith("content://")) + Uri.parse(item.path) + else File(item.path) + ) + .crossfade(true) + .size(600) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .clip(RoundedCornerShape(4.dp)), + ) + if (!item.caption.isNullOrBlank()) { + Text( + item.caption, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } + } + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index bca960b..36d97ed 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -875,6 +875,10 @@ interface MomentDao { /** 全库关键词搜索朋友圈文字。 */ @Query("SELECT * FROM moments WHERE text LIKE '%' || :q || '%' ORDER BY createTime DESC LIMIT :n") suspend fun searchText(q: String, n: Int): List + + /** 时间轴用:最近 n 条朋友圈(所有人混排)。 */ + @Query("SELECT * FROM moments ORDER BY createTime DESC LIMIT :n") + fun observeRecent(n: Int): Flow> } @Dao @@ -911,6 +915,10 @@ interface WechatMessageDao { ORDER BY createTime DESC LIMIT :n """) suspend fun searchGroup(q: String, n: Int): List + + /** 时间轴用:最近 n 条私聊(排除群聊)。 */ + @Query("SELECT * FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' ORDER BY createTime DESC LIMIT :n") + fun observeRecent(n: Int): Flow> } @Dao diff --git a/lifelog-recorder/app/src/main/res/drawable/ic_launcher_background.xml b/lifelog-recorder/app/src/main/res/drawable/ic_launcher_background.xml index 688d062..a6391a7 100644 --- a/lifelog-recorder/app/src/main/res/drawable/ic_launcher_background.xml +++ b/lifelog-recorder/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,8 +1,8 @@ diff --git a/lifelog-recorder/app/src/main/res/drawable/ic_launcher_foreground.xml b/lifelog-recorder/app/src/main/res/drawable/ic_launcher_foreground.xml index f28d3c1..9481f58 100644 --- a/lifelog-recorder/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/lifelog-recorder/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,8 +1,9 @@ - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - + + - - - - + + - - - - + + - - + - - - - + + + + + diff --git a/lifelog-recorder/app/src/main/res/values/strings.xml b/lifelog-recorder/app/src/main/res/values/strings.xml index bd0fa47..ca2ad19 100644 --- a/lifelog-recorder/app/src/main/res/values/strings.xml +++ b/lifelog-recorder/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Lifelog + Life OS