Commit 6e19c01479a70af4337ac8b8de9088f0207bb370
1 parent
fedfcf46
feat: 品牌重命名 Life OS + 跨源统一时间轴
- app 名称:Lifelog → Life OS(strings.xml + AndroidManifest 硬编码修正) - 图标:红色关系网络 → 深紫渐变轨道设计(人物居中 + 双同心环 + 卫星节点) - 新增 TimelineScreen:录音/微信/朋友圈/照片四源按时间混排,日期分隔头,来源过滤 chip - 底部导航加「时间轴」tab(第三位:对话/人物/时间轴/录音/相册/设置) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
7 changed files
with
425 additions
and
87 deletions
lifelog-recorder/app/src/main/AndroidManifest.xml
| @@ -21,7 +21,7 @@ | @@ -21,7 +21,7 @@ | ||
| 21 | 21 | ||
| 22 | <application | 22 | <application |
| 23 | android:name=".LifelogApp" | 23 | android:name=".LifelogApp" |
| 24 | - android:label="Lifelog" | 24 | + android:label="@string/app_name" |
| 25 | android:icon="@mipmap/ic_launcher" | 25 | android:icon="@mipmap/ic_launcher" |
| 26 | android:roundIcon="@mipmap/ic_launcher_round" | 26 | android:roundIcon="@mipmap/ic_launcher_round" |
| 27 | android:allowBackup="false" | 27 | android:allowBackup="false" |
| @@ -33,7 +33,7 @@ | @@ -33,7 +33,7 @@ | ||
| 33 | <activity | 33 | <activity |
| 34 | android:name=".MainActivity" | 34 | android:name=".MainActivity" |
| 35 | android:exported="true" | 35 | android:exported="true" |
| 36 | - android:label="Lifelog" | 36 | + android:label="@string/app_name" |
| 37 | android:windowSoftInputMode="adjustResize" | 37 | android:windowSoftInputMode="adjustResize" |
| 38 | android:theme="@style/Theme.Lifelog"> | 38 | android:theme="@style/Theme.Lifelog"> |
| 39 | <intent-filter> | 39 | <intent-filter> |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| @@ -122,8 +122,9 @@ class MainActivity : ComponentActivity() { | @@ -122,8 +122,9 @@ class MainActivity : ComponentActivity() { | ||
| 122 | } | 122 | } |
| 123 | 123 | ||
| 124 | private enum class Tab(val label: String, val icon: String) { | 124 | private enum class Tab(val label: String, val icon: String) { |
| 125 | - Chat("对话", "💬"), // 默认首页:AI 对话 + 工具模式 | 125 | + Chat("对话", "💬"), |
| 126 | People("人物", "👤"), | 126 | People("人物", "👤"), |
| 127 | + Timeline("时间轴", "🕐"), // Life OS 核心:跨源统一时间轴 | ||
| 127 | Recording("录音", "⏺"), | 128 | Recording("录音", "⏺"), |
| 128 | Album("相册", "🖼"), | 129 | Album("相册", "🖼"), |
| 129 | Settings("设置", "⚙"), | 130 | Settings("设置", "⚙"), |
| @@ -175,6 +176,7 @@ private fun MainScreen() { | @@ -175,6 +176,7 @@ private fun MainScreen() { | ||
| 175 | ) { inner -> | 176 | ) { inner -> |
| 176 | Box(modifier = Modifier.padding(inner)) { | 177 | Box(modifier = Modifier.padding(inner)) { |
| 177 | when (tab) { | 178 | when (tab) { |
| 179 | + Tab.Timeline -> TimelineScreen() | ||
| 178 | Tab.People -> ContactsTab(repo) | 180 | Tab.People -> ContactsTab(repo) |
| 179 | Tab.Recording -> RecordingScreen( | 181 | Tab.Recording -> RecordingScreen( |
| 180 | activity = activity, repo = repo, speakers = speakers, | 182 | activity = activity, repo = repo, speakers = speakers, |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TimelineScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | ||
| 2 | + | ||
| 3 | +import android.app.Application | ||
| 4 | +import android.net.Uri | ||
| 5 | +import androidx.compose.foundation.background | ||
| 6 | +import androidx.compose.foundation.horizontalScroll | ||
| 7 | +import androidx.compose.foundation.layout.Arrangement | ||
| 8 | +import androidx.compose.foundation.layout.Box | ||
| 9 | +import androidx.compose.foundation.layout.Column | ||
| 10 | +import androidx.compose.foundation.layout.PaddingValues | ||
| 11 | +import androidx.compose.foundation.layout.Row | ||
| 12 | +import androidx.compose.foundation.layout.fillMaxSize | ||
| 13 | +import androidx.compose.foundation.layout.fillMaxWidth | ||
| 14 | +import androidx.compose.foundation.layout.height | ||
| 15 | +import androidx.compose.foundation.layout.padding | ||
| 16 | +import androidx.compose.foundation.layout.size | ||
| 17 | +import androidx.compose.foundation.layout.width | ||
| 18 | +import androidx.compose.foundation.lazy.LazyColumn | ||
| 19 | +import androidx.compose.foundation.lazy.items | ||
| 20 | +import androidx.compose.foundation.rememberScrollState | ||
| 21 | +import androidx.compose.foundation.shape.CircleShape | ||
| 22 | +import androidx.compose.foundation.shape.RoundedCornerShape | ||
| 23 | +import androidx.compose.material3.FilterChip | ||
| 24 | +import androidx.compose.material3.MaterialTheme | ||
| 25 | +import androidx.compose.material3.Surface | ||
| 26 | +import androidx.compose.material3.Text | ||
| 27 | +import androidx.compose.runtime.Composable | ||
| 28 | +import androidx.compose.runtime.collectAsState | ||
| 29 | +import androidx.compose.runtime.getValue | ||
| 30 | +import androidx.compose.runtime.mutableStateOf | ||
| 31 | +import androidx.compose.runtime.remember | ||
| 32 | +import androidx.compose.runtime.setValue | ||
| 33 | +import androidx.compose.ui.Alignment | ||
| 34 | +import androidx.compose.ui.Modifier | ||
| 35 | +import androidx.compose.ui.draw.clip | ||
| 36 | +import androidx.compose.ui.graphics.Color | ||
| 37 | +import androidx.compose.ui.layout.ContentScale | ||
| 38 | +import androidx.compose.ui.platform.LocalContext | ||
| 39 | +import androidx.compose.ui.text.font.FontWeight | ||
| 40 | +import androidx.compose.ui.text.style.TextOverflow | ||
| 41 | +import androidx.compose.ui.unit.dp | ||
| 42 | +import androidx.compose.ui.unit.sp | ||
| 43 | +import androidx.lifecycle.AndroidViewModel | ||
| 44 | +import androidx.lifecycle.viewModelScope | ||
| 45 | +import androidx.lifecycle.viewmodel.compose.viewModel | ||
| 46 | +import coil.compose.AsyncImage | ||
| 47 | +import coil.request.ImageRequest | ||
| 48 | +import com.lemon.lifelog.data.Moment | ||
| 49 | +import com.lemon.lifelog.data.Person | ||
| 50 | +import com.lemon.lifelog.data.PhotoRow | ||
| 51 | +import com.lemon.lifelog.data.Repository | ||
| 52 | +import com.lemon.lifelog.data.SegmentRow | ||
| 53 | +import com.lemon.lifelog.data.WechatMessage | ||
| 54 | +import kotlinx.coroutines.flow.SharingStarted | ||
| 55 | +import kotlinx.coroutines.flow.combine | ||
| 56 | +import kotlinx.coroutines.flow.stateIn | ||
| 57 | +import java.io.File | ||
| 58 | +import java.text.SimpleDateFormat | ||
| 59 | +import java.util.Date | ||
| 60 | +import java.util.Locale | ||
| 61 | + | ||
| 62 | +// ─── 数据模型 ─────────────────────────────────────────────────────────────── | ||
| 63 | + | ||
| 64 | +sealed class TimelineItem { | ||
| 65 | + abstract val ts: Long | ||
| 66 | + | ||
| 67 | + data class Voice(val segId: Long, override val ts: Long, | ||
| 68 | + val personName: String?, val text: String) : TimelineItem() | ||
| 69 | + | ||
| 70 | + data class WeChat(val id: Long, override val ts: Long, | ||
| 71 | + val personName: String?, val content: String, | ||
| 72 | + val isSend: Boolean) : TimelineItem() | ||
| 73 | + | ||
| 74 | + data class Moment(val id: Long, override val ts: Long, | ||
| 75 | + val personName: String?, val text: String) : TimelineItem() | ||
| 76 | + | ||
| 77 | + data class Photo(val id: Long, override val ts: Long, | ||
| 78 | + val path: String, val caption: String?, | ||
| 79 | + val faceCount: Int) : TimelineItem() | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +enum class TlSource(val label: String, val icon: String) { | ||
| 83 | + VOICE("录音", "🎙"), | ||
| 84 | + WECHAT("微信", "💬"), | ||
| 85 | + MOMENT("朋友圈", "👥"), | ||
| 86 | + PHOTO("照片", "📷"), | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// ─── ViewModel ────────────────────────────────────────────────────────────── | ||
| 90 | + | ||
| 91 | +class TimelineViewModel(app: Application) : AndroidViewModel(app) { | ||
| 92 | + private val repo = Repository(app) | ||
| 93 | + | ||
| 94 | + val items = combine( | ||
| 95 | + repo.segments.observeTimeline(300), | ||
| 96 | + repo.wechatMessages.observeRecent(300), | ||
| 97 | + repo.moments.observeRecent(300), | ||
| 98 | + repo.photos.observeGrid(300), | ||
| 99 | + repo.persons.observeAll(), | ||
| 100 | + ) { segs: List<SegmentRow>, msgs: List<WechatMessage>, moments: List<Moment>, | ||
| 101 | + photos: List<PhotoRow>, persons: List<Person> -> | ||
| 102 | + | ||
| 103 | + val wxMap = persons.mapNotNull { p -> | ||
| 104 | + val ref = p.contactRef ?: return@mapNotNull null | ||
| 105 | + if (!ref.startsWith("wechat:")) return@mapNotNull null | ||
| 106 | + ref.removePrefix("wechat:") to p.name | ||
| 107 | + }.toMap() | ||
| 108 | + | ||
| 109 | + buildList { | ||
| 110 | + segs.forEach { s -> | ||
| 111 | + val t = s.text?.takeIf { it.isNotBlank() } ?: return@forEach | ||
| 112 | + add(TimelineItem.Voice(s.id, s.startTs, s.speakerName ?: s.personName, t)) | ||
| 113 | + } | ||
| 114 | + msgs.forEach { m -> | ||
| 115 | + if (m.content.isBlank()) return@forEach | ||
| 116 | + add(TimelineItem.WeChat(m.id, m.createTime, wxMap[m.talker], m.content, m.isSend)) | ||
| 117 | + } | ||
| 118 | + moments.forEach { m -> | ||
| 119 | + if (m.text.isBlank()) return@forEach | ||
| 120 | + add(TimelineItem.Moment(m.id, m.createTime, wxMap[m.userName] ?: m.userName, m.text)) | ||
| 121 | + } | ||
| 122 | + photos.filter { it.scanState >= 1 || it.faceCount > 0 || !it.caption.isNullOrBlank() } | ||
| 123 | + .forEach { p -> | ||
| 124 | + add(TimelineItem.Photo(p.id, p.takenTs, p.path, p.caption, p.faceCount)) | ||
| 125 | + } | ||
| 126 | + }.sortedByDescending { it.ts } | ||
| 127 | + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +// ─── 辅助:带日期分隔头的扁平列表 ───────────────────────────────────────── | ||
| 131 | + | ||
| 132 | +private sealed class TlEntry { | ||
| 133 | + data class Header(val label: String) : TlEntry() | ||
| 134 | + data class Event(val item: TimelineItem) : TlEntry() | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) | ||
| 138 | +private val lblFmt = SimpleDateFormat("M月d日", Locale.CHINESE) | ||
| 139 | +private val timeFmt = SimpleDateFormat("HH:mm", Locale.getDefault()) | ||
| 140 | + | ||
| 141 | +private fun buildEntries(items: List<TimelineItem>): List<TlEntry> { | ||
| 142 | + val result = mutableListOf<TlEntry>() | ||
| 143 | + var lastDay = "" | ||
| 144 | + val today = dayFmt.format(System.currentTimeMillis()) | ||
| 145 | + val yesterday = dayFmt.format(System.currentTimeMillis() - 86_400_000L) | ||
| 146 | + for (item in items) { | ||
| 147 | + val day = dayFmt.format(Date(item.ts)) | ||
| 148 | + if (day != lastDay) { | ||
| 149 | + result += TlEntry.Header(when (day) { | ||
| 150 | + today -> "今天" | ||
| 151 | + yesterday -> "昨天" | ||
| 152 | + else -> lblFmt.format(Date(item.ts)) | ||
| 153 | + }) | ||
| 154 | + lastDay = day | ||
| 155 | + } | ||
| 156 | + result += TlEntry.Event(item) | ||
| 157 | + } | ||
| 158 | + return result | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +// ─── Screen ───────────────────────────────────────────────────────────────── | ||
| 162 | + | ||
| 163 | +@Composable | ||
| 164 | +fun TimelineScreen() { | ||
| 165 | + val vm: TimelineViewModel = viewModel() | ||
| 166 | + val allItems by vm.items.collectAsState() | ||
| 167 | + | ||
| 168 | + var activeFilters by remember { mutableStateOf(TlSource.entries.toSet()) } | ||
| 169 | + | ||
| 170 | + val filtered = remember(allItems, activeFilters) { | ||
| 171 | + allItems.filter { item -> | ||
| 172 | + when (item) { | ||
| 173 | + is TimelineItem.Voice -> TlSource.VOICE in activeFilters | ||
| 174 | + is TimelineItem.WeChat -> TlSource.WECHAT in activeFilters | ||
| 175 | + is TimelineItem.Moment -> TlSource.MOMENT in activeFilters | ||
| 176 | + is TimelineItem.Photo -> TlSource.PHOTO in activeFilters | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + val entries = remember(filtered) { buildEntries(filtered) } | ||
| 182 | + | ||
| 183 | + Column(Modifier.fillMaxSize()) { | ||
| 184 | + // 过滤 chips | ||
| 185 | + Row( | ||
| 186 | + modifier = Modifier | ||
| 187 | + .horizontalScroll(rememberScrollState()) | ||
| 188 | + .padding(horizontal = 12.dp, vertical = 6.dp), | ||
| 189 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 190 | + ) { | ||
| 191 | + val allOn = activeFilters.size == TlSource.entries.size | ||
| 192 | + FilterChip( | ||
| 193 | + selected = allOn, | ||
| 194 | + onClick = { | ||
| 195 | + activeFilters = | ||
| 196 | + if (allOn) emptySet() else TlSource.entries.toSet() | ||
| 197 | + }, | ||
| 198 | + label = { Text("全部") }, | ||
| 199 | + ) | ||
| 200 | + TlSource.entries.forEach { src -> | ||
| 201 | + FilterChip( | ||
| 202 | + selected = src in activeFilters, | ||
| 203 | + onClick = { | ||
| 204 | + activeFilters = | ||
| 205 | + if (src in activeFilters) activeFilters - src | ||
| 206 | + else activeFilters + src | ||
| 207 | + }, | ||
| 208 | + label = { Text("${src.icon} ${src.label}") }, | ||
| 209 | + ) | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + if (entries.isEmpty()) { | ||
| 214 | + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | ||
| 215 | + Text("暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant) | ||
| 216 | + } | ||
| 217 | + } else { | ||
| 218 | + LazyColumn( | ||
| 219 | + modifier = Modifier.fillMaxSize(), | ||
| 220 | + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), | ||
| 221 | + verticalArrangement = Arrangement.spacedBy(2.dp), | ||
| 222 | + ) { | ||
| 223 | + items( | ||
| 224 | + entries, | ||
| 225 | + key = { e -> | ||
| 226 | + when (e) { | ||
| 227 | + is TlEntry.Header -> "h_${e.label}" | ||
| 228 | + is TlEntry.Event -> when (val i = e.item) { | ||
| 229 | + is TimelineItem.Voice -> "v_${i.segId}" | ||
| 230 | + is TimelineItem.WeChat -> "w_${i.id}" | ||
| 231 | + is TimelineItem.Moment -> "mo_${i.id}" | ||
| 232 | + is TimelineItem.Photo -> "p_${i.id}" | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | + }, | ||
| 236 | + ) { entry -> | ||
| 237 | + when (entry) { | ||
| 238 | + is TlEntry.Header -> TlDateHeader(entry.label) | ||
| 239 | + is TlEntry.Event -> TlEventRow(entry.item) | ||
| 240 | + } | ||
| 241 | + } | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + } | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +// ─── 子组件 ────────────────────────────────────────────────────────────────── | ||
| 248 | + | ||
| 249 | +@Composable | ||
| 250 | +private fun TlDateHeader(label: String) { | ||
| 251 | + Text( | ||
| 252 | + text = label, | ||
| 253 | + style = MaterialTheme.typography.labelMedium, | ||
| 254 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | ||
| 255 | + modifier = Modifier | ||
| 256 | + .fillMaxWidth() | ||
| 257 | + .padding(top = 14.dp, bottom = 4.dp, start = 4.dp), | ||
| 258 | + ) | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +private data class SrcStyle(val icon: String, val color: Color) | ||
| 262 | + | ||
| 263 | +private fun srcStyle(item: TimelineItem) = when (item) { | ||
| 264 | + is TimelineItem.Voice -> SrcStyle("🎙", Color(0xFF7C4DFF)) | ||
| 265 | + is TimelineItem.WeChat -> SrcStyle("💬", Color(0xFF43A047)) | ||
| 266 | + is TimelineItem.Moment -> SrcStyle("👥", Color(0xFF1E88E5)) | ||
| 267 | + is TimelineItem.Photo -> SrcStyle("📷", Color(0xFFFF9800)) | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +@Composable | ||
| 271 | +private fun TlEventRow(item: TimelineItem) { | ||
| 272 | + val (icon, color) = srcStyle(item) | ||
| 273 | + Row( | ||
| 274 | + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), | ||
| 275 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 276 | + verticalAlignment = Alignment.Top, | ||
| 277 | + ) { | ||
| 278 | + // 左侧:图标 + 时间 | ||
| 279 | + Column( | ||
| 280 | + horizontalAlignment = Alignment.CenterHorizontally, | ||
| 281 | + modifier = Modifier.width(44.dp).padding(top = 2.dp), | ||
| 282 | + ) { | ||
| 283 | + Box( | ||
| 284 | + Modifier | ||
| 285 | + .size(28.dp) | ||
| 286 | + .background(color.copy(alpha = 0.15f), CircleShape), | ||
| 287 | + contentAlignment = Alignment.Center, | ||
| 288 | + ) { | ||
| 289 | + Text(icon, fontSize = 14.sp) | ||
| 290 | + } | ||
| 291 | + Text( | ||
| 292 | + timeFmt.format(Date(item.ts)), | ||
| 293 | + style = MaterialTheme.typography.labelSmall, | ||
| 294 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | ||
| 295 | + modifier = Modifier.padding(top = 2.dp), | ||
| 296 | + ) | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + // 右侧:内容卡片 | ||
| 300 | + Surface( | ||
| 301 | + modifier = Modifier.weight(1f), | ||
| 302 | + shape = RoundedCornerShape(8.dp), | ||
| 303 | + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), | ||
| 304 | + ) { | ||
| 305 | + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { | ||
| 306 | + // 人名标签 | ||
| 307 | + val name: String? = when (item) { | ||
| 308 | + is TimelineItem.Voice -> item.personName | ||
| 309 | + is TimelineItem.WeChat -> if (item.isSend) "我" else item.personName | ||
| 310 | + is TimelineItem.Moment -> item.personName | ||
| 311 | + is TimelineItem.Photo -> null | ||
| 312 | + } | ||
| 313 | + if (name != null) { | ||
| 314 | + Text( | ||
| 315 | + name, | ||
| 316 | + style = MaterialTheme.typography.labelSmall, | ||
| 317 | + color = color, | ||
| 318 | + fontWeight = FontWeight.SemiBold, | ||
| 319 | + modifier = Modifier.padding(bottom = 2.dp), | ||
| 320 | + ) | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + // 正文 | ||
| 324 | + when (item) { | ||
| 325 | + is TimelineItem.Voice -> Text( | ||
| 326 | + item.text, | ||
| 327 | + style = MaterialTheme.typography.bodySmall, | ||
| 328 | + maxLines = 2, | ||
| 329 | + overflow = TextOverflow.Ellipsis, | ||
| 330 | + ) | ||
| 331 | + is TimelineItem.WeChat -> Text( | ||
| 332 | + item.content, | ||
| 333 | + style = MaterialTheme.typography.bodySmall, | ||
| 334 | + maxLines = 2, | ||
| 335 | + overflow = TextOverflow.Ellipsis, | ||
| 336 | + ) | ||
| 337 | + is TimelineItem.Moment -> Text( | ||
| 338 | + item.text, | ||
| 339 | + style = MaterialTheme.typography.bodySmall, | ||
| 340 | + maxLines = 3, | ||
| 341 | + overflow = TextOverflow.Ellipsis, | ||
| 342 | + ) | ||
| 343 | + is TimelineItem.Photo -> { | ||
| 344 | + val ctx = LocalContext.current | ||
| 345 | + AsyncImage( | ||
| 346 | + model = ImageRequest.Builder(ctx) | ||
| 347 | + .data( | ||
| 348 | + if (item.path.startsWith("content://")) | ||
| 349 | + Uri.parse(item.path) | ||
| 350 | + else File(item.path) | ||
| 351 | + ) | ||
| 352 | + .crossfade(true) | ||
| 353 | + .size(600) | ||
| 354 | + .build(), | ||
| 355 | + contentDescription = null, | ||
| 356 | + contentScale = ContentScale.Crop, | ||
| 357 | + modifier = Modifier | ||
| 358 | + .fillMaxWidth() | ||
| 359 | + .height(96.dp) | ||
| 360 | + .clip(RoundedCornerShape(4.dp)), | ||
| 361 | + ) | ||
| 362 | + if (!item.caption.isNullOrBlank()) { | ||
| 363 | + Text( | ||
| 364 | + item.caption, | ||
| 365 | + style = MaterialTheme.typography.bodySmall, | ||
| 366 | + maxLines = 2, | ||
| 367 | + overflow = TextOverflow.Ellipsis, | ||
| 368 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | ||
| 369 | + modifier = Modifier.padding(top = 4.dp), | ||
| 370 | + ) | ||
| 371 | + } | ||
| 372 | + } | ||
| 373 | + } | ||
| 374 | + } | ||
| 375 | + } | ||
| 376 | + } | ||
| 377 | +} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -875,6 +875,10 @@ interface MomentDao { | @@ -875,6 +875,10 @@ interface MomentDao { | ||
| 875 | /** 全库关键词搜索朋友圈文字。 */ | 875 | /** 全库关键词搜索朋友圈文字。 */ |
| 876 | @Query("SELECT * FROM moments WHERE text LIKE '%' || :q || '%' ORDER BY createTime DESC LIMIT :n") | 876 | @Query("SELECT * FROM moments WHERE text LIKE '%' || :q || '%' ORDER BY createTime DESC LIMIT :n") |
| 877 | suspend fun searchText(q: String, n: Int): List<Moment> | 877 | suspend fun searchText(q: String, n: Int): List<Moment> |
| 878 | + | ||
| 879 | + /** 时间轴用:最近 n 条朋友圈(所有人混排)。 */ | ||
| 880 | + @Query("SELECT * FROM moments ORDER BY createTime DESC LIMIT :n") | ||
| 881 | + fun observeRecent(n: Int): Flow<List<Moment>> | ||
| 878 | } | 882 | } |
| 879 | 883 | ||
| 880 | @Dao | 884 | @Dao |
| @@ -911,6 +915,10 @@ interface WechatMessageDao { | @@ -911,6 +915,10 @@ interface WechatMessageDao { | ||
| 911 | ORDER BY createTime DESC LIMIT :n | 915 | ORDER BY createTime DESC LIMIT :n |
| 912 | """) | 916 | """) |
| 913 | suspend fun searchGroup(q: String, n: Int): List<WechatMessage> | 917 | suspend fun searchGroup(q: String, n: Int): List<WechatMessage> |
| 918 | + | ||
| 919 | + /** 时间轴用:最近 n 条私聊(排除群聊)。 */ | ||
| 920 | + @Query("SELECT * FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' ORDER BY createTime DESC LIMIT :n") | ||
| 921 | + fun observeRecent(n: Int): Flow<List<WechatMessage>> | ||
| 914 | } | 922 | } |
| 915 | 923 | ||
| 916 | @Dao | 924 | @Dao |
lifelog-recorder/app/src/main/res/drawable/ic_launcher_background.xml
| 1 | <?xml version="1.0" encoding="utf-8"?> | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | 2 | <shape xmlns:android="http://schemas.android.com/apk/res/android"> |
| 3 | <gradient | 3 | <gradient |
| 4 | - android:startColor="#C62828" | ||
| 5 | - android:endColor="#FF5252" | 4 | + android:startColor="#0F0A25" |
| 5 | + android:endColor="#5B0EA6" | ||
| 6 | android:type="linear" | 6 | android:type="linear" |
| 7 | android:angle="315"/> | 7 | android:angle="315"/> |
| 8 | </shape> | 8 | </shape> |
lifelog-recorder/app/src/main/res/drawable/ic_launcher_foreground.xml
| 1 | <?xml version="1.0" encoding="utf-8"?> | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | <!-- | 2 | <!-- |
| 3 | - 人际关系网络图标(108×108dp viewport,safe zone 18–90) | ||
| 4 | - 布局:中央大节点("我")+ 4个卫星节点(上/右/下/左),白色连线+节点, | ||
| 5 | - 中央节点内嵌红色人物轮廓(圆形头 + 肩线),卫星节点为纯白圆。 | 3 | + Life OS 图标(108×108dp viewport,safe zone 18–90) |
| 4 | + 布局:中央白圆盘(紫色人物轮廓)+ 两圈同心轨道环(白色半透明) | ||
| 5 | + + 对角两颗卫星节点(白色实心圆) | ||
| 6 | + 隐喻:你是中心,Memory/Relationship/Goal/Execution 各层围绕你运转 | ||
| 6 | --> | 7 | --> |
| 7 | <vector xmlns:android="http://schemas.android.com/apk/res/android" | 8 | <vector xmlns:android="http://schemas.android.com/apk/res/android" |
| 8 | android:width="108dp" | 9 | android:width="108dp" |
| @@ -10,90 +11,40 @@ | @@ -10,90 +11,40 @@ | ||
| 10 | android:viewportWidth="108" | 11 | android:viewportWidth="108" |
| 11 | android:viewportHeight="108"> | 12 | android:viewportHeight="108"> |
| 12 | 13 | ||
| 13 | - <!-- ── 连线(在节点层之下,白色半透明) ── --> | ||
| 14 | - <!-- 中央 → 上 --> | ||
| 15 | - <path android:pathData="M54,39 L54,31" | ||
| 16 | - android:strokeColor="#FFFFFF" android:strokeWidth="3.5" | ||
| 17 | - android:strokeAlpha="0.75" android:strokeLineCap="round" | ||
| 18 | - android:fillColor="#00000000"/> | ||
| 19 | - <!-- 中央 → 右 --> | ||
| 20 | - <path android:pathData="M69,54 L79,54" | ||
| 21 | - android:strokeColor="#FFFFFF" android:strokeWidth="3.5" | ||
| 22 | - android:strokeAlpha="0.75" android:strokeLineCap="round" | ||
| 23 | - android:fillColor="#00000000"/> | ||
| 24 | - <!-- 中央 → 下 --> | ||
| 25 | - <path android:pathData="M54,69 L54,77" | ||
| 26 | - android:strokeColor="#FFFFFF" android:strokeWidth="3.5" | ||
| 27 | - android:strokeAlpha="0.75" android:strokeLineCap="round" | ||
| 28 | - android:fillColor="#00000000"/> | ||
| 29 | - <!-- 中央 → 左 --> | ||
| 30 | - <path android:pathData="M39,54 L29,54" | ||
| 31 | - android:strokeColor="#FFFFFF" android:strokeWidth="3.5" | ||
| 32 | - android:strokeAlpha="0.75" android:strokeLineCap="round" | ||
| 33 | - android:fillColor="#00000000"/> | ||
| 34 | - <!-- 次级连线:上 → 右(让图更像真实网络) --> | ||
| 35 | - <path android:pathData="M62,23 L80,46" | ||
| 36 | - android:strokeColor="#FFFFFF" android:strokeWidth="2" | ||
| 37 | - android:strokeAlpha="0.45" android:strokeLineCap="round" | ||
| 38 | - android:fillColor="#00000000"/> | ||
| 39 | - <!-- 次级连线:下 → 左 --> | ||
| 40 | - <path android:pathData="M46,85 L28,62" | ||
| 41 | - android:strokeColor="#FFFFFF" android:strokeWidth="2" | ||
| 42 | - android:strokeAlpha="0.45" android:strokeLineCap="round" | 14 | + <!-- 外轨道环 r=33,细描边,低透明度 --> |
| 15 | + <path android:pathData="M21,54 A33,33 0 1,0 87,54 A33,33 0 1,0 21,54" | ||
| 16 | + android:strokeColor="#FFFFFF" | ||
| 17 | + android:strokeWidth="2.5" | ||
| 18 | + android:strokeAlpha="0.55" | ||
| 43 | android:fillColor="#00000000"/> | 19 | android:fillColor="#00000000"/> |
| 44 | 20 | ||
| 45 | - <!-- ── 卫星节点(白色实心圆,r=10) ── --> | ||
| 46 | - <!-- 上 --> | ||
| 47 | - <path android:pathData="M54,21 m-10,0 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0" | ||
| 48 | - android:fillColor="#FFFFFF"/> | ||
| 49 | - <!-- 右 --> | ||
| 50 | - <path android:pathData="M89,54 m-10,0 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0" | ||
| 51 | - android:fillColor="#FFFFFF"/> | ||
| 52 | - <!-- 下 --> | ||
| 53 | - <path android:pathData="M54,87 m-10,0 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0" | ||
| 54 | - android:fillColor="#FFFFFF"/> | ||
| 55 | - <!-- 左 --> | ||
| 56 | - <path android:pathData="M19,54 m-10,0 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0" | ||
| 57 | - android:fillColor="#FFFFFF"/> | ||
| 58 | - | ||
| 59 | - <!-- 卫星节点内小人(头+肩,红色)以"上"节点为例,其他类似 --> | ||
| 60 | - <!-- 上节点人物头 --> | ||
| 61 | - <path android:pathData="M54,18 m-3.5,0 a3.5,3.5 0 1,0 7,0 a3.5,3.5 0 1,0 -7,0" | ||
| 62 | - android:fillColor="#C62828"/> | ||
| 63 | - <!-- 上节点人物肩 --> | ||
| 64 | - <path android:pathData="M49,25 C49,22.5 51.2,21 54,21 C56.8,21 59,22.5 59,25 Z" | ||
| 65 | - android:fillColor="#C62828"/> | ||
| 66 | - | ||
| 67 | - <!-- 右节点人物头 --> | ||
| 68 | - <path android:pathData="M89,51 m-3.5,0 a3.5,3.5 0 1,0 7,0 a3.5,3.5 0 1,0 -7,0" | ||
| 69 | - android:fillColor="#C62828"/> | ||
| 70 | - <!-- 右节点人物肩 --> | ||
| 71 | - <path android:pathData="M84,58 C84,55.5 86.2,54 89,54 C91.8,54 94,55.5 94,58 Z" | ||
| 72 | - android:fillColor="#C62828"/> | 21 | + <!-- 内轨道环 r=22,更细,更低透明度 --> |
| 22 | + <path android:pathData="M32,54 A22,22 0 1,0 76,54 A22,22 0 1,0 32,54" | ||
| 23 | + android:strokeColor="#FFFFFF" | ||
| 24 | + android:strokeWidth="2" | ||
| 25 | + android:strokeAlpha="0.35" | ||
| 26 | + android:fillColor="#00000000"/> | ||
| 73 | 27 | ||
| 74 | - <!-- 下节点人物头 --> | ||
| 75 | - <path android:pathData="M54,84 m-3.5,0 a3.5,3.5 0 1,0 7,0 a3.5,3.5 0 1,0 -7,0" | ||
| 76 | - android:fillColor="#C62828"/> | ||
| 77 | - <!-- 下节点人物肩 --> | ||
| 78 | - <path android:pathData="M49,91 C49,88.5 51.2,87 54,87 C56.8,87 59,88.5 59,91 Z" | ||
| 79 | - android:fillColor="#C62828"/> | 28 | + <!-- 卫星节点:右上方(约 77,31),在外轨道 45° 位置 --> |
| 29 | + <path android:pathData="M72,31 A5,5 0 1,0 82,31 A5,5 0 1,0 72,31" | ||
| 30 | + android:fillColor="#FFFFFF" | ||
| 31 | + android:fillAlpha="0.9"/> | ||
| 80 | 32 | ||
| 81 | - <!-- 左节点人物头 --> | ||
| 82 | - <path android:pathData="M19,51 m-3.5,0 a3.5,3.5 0 1,0 7,0 a3.5,3.5 0 1,0 -7,0" | ||
| 83 | - android:fillColor="#C62828"/> | ||
| 84 | - <!-- 左节点人物肩 --> | ||
| 85 | - <path android:pathData="M14,58 C14,55.5 16.2,54 19,54 C21.8,54 24,55.5 24,58 Z" | ||
| 86 | - android:fillColor="#C62828"/> | 33 | + <!-- 卫星节点:左下方(约 31,77),在外轨道 225° 位置 --> |
| 34 | + <path android:pathData="M26,77 A5,5 0 1,0 36,77 A5,5 0 1,0 26,77" | ||
| 35 | + android:fillColor="#FFFFFF" | ||
| 36 | + android:fillAlpha="0.9"/> | ||
| 87 | 37 | ||
| 88 | - <!-- ── 中央大节点(白色实心圆,r=15) ── --> | ||
| 89 | - <path android:pathData="M54,54 m-15,0 a15,15 0 1,0 30,0 a15,15 0 1,0 -30,0" | 38 | + <!-- 中央白色圆盘 r=13 --> |
| 39 | + <path android:pathData="M41,54 A13,13 0 1,0 67,54 A13,13 0 1,0 41,54" | ||
| 90 | android:fillColor="#FFFFFF"/> | 40 | android:fillColor="#FFFFFF"/> |
| 91 | 41 | ||
| 92 | - <!-- 中央节点内人物头(红色,r=5.5) --> | ||
| 93 | - <path android:pathData="M54,49 m-5.5,0 a5.5,5.5 0 1,0 11,0 a5.5,5.5 0 1,0 -11,0" | ||
| 94 | - android:fillColor="#C62828"/> | ||
| 95 | - <!-- 中央节点内人物肩(红色) --> | ||
| 96 | - <path android:pathData="M44,62 C44,57.5 48.5,54.5 54,54.5 C59.5,54.5 64,57.5 64,62 Z" | ||
| 97 | - android:fillColor="#C62828"/> | 42 | + <!-- 人物头像(紫色,r=4,中心 54,49)—— 在白圆盘内显示为实色 --> |
| 43 | + <path android:pathData="M50,49 A4,4 0 1,0 58,49 A4,4 0 1,0 50,49" | ||
| 44 | + android:fillColor="#5B0EA6"/> | ||
| 45 | + | ||
| 46 | + <!-- 人物肩膀(紫色曲线,弧形填充) --> | ||
| 47 | + <path android:pathData="M46,62 C46,57.5 49.8,55 54,55 C58.2,55 62,57.5 62,62 Z" | ||
| 48 | + android:fillColor="#5B0EA6"/> | ||
| 98 | 49 | ||
| 99 | </vector> | 50 | </vector> |
lifelog-recorder/app/src/main/res/values/strings.xml