Commit 6e19c01479a70af4337ac8b8de9088f0207bb370

Authored by yibo
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>
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
1 <?xml version="1.0" encoding="utf-8"?> 1 <?xml version="1.0" encoding="utf-8"?>
2 <resources> 2 <resources>
3 - <string name="app_name">Lifelog</string> 3 + <string name="app_name">Life OS</string>
4 </resources> 4 </resources>