Commit 11c471babe73b27e04f47acda0a697ff884ec286

Authored by yibo
1 parent 6f509ad8

feat(goal): P2 #6/#7 — GoalScreen UI + Followup 绑定目标

GoalScreen(新建):
- 进行中 / 已完成分组;状态切换(✓完成 / 🔄重开);删除;
- 新建对话框:目标内容 + 截止日期 + 关联人物下拉
- 删除目标自动解绑其下所有待跟进

PhotologScreen:
- ContactsTab 加 showGoals 状态 + GoalScreen 导航
- ContactList 工具栏加「🎯 目标」入口按钮

FollowupsScreen 重写:
- 每条待办显示「🎯 目标名」标签
- 点击弹出 GoalPickerDialog,可绑定/解绑目标

PhotologViewModel:
- createGoal / updateGoalStatus / deleteGoal / linkFollowupToGoal

ChatScreen:
- 键盘 ImeAction.Send 支持 + 发送后自动收键盘

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/LIFEOS_ROADMAP.md
@@ -159,26 +159,20 @@ AI 工具加 `find_relevant_persons(goal: String)`: @@ -159,26 +159,20 @@ AI 工具加 `find_relevant_persons(goal: String)`:
159 159
160 --- 160 ---
161 161
162 -### P2 — 搭 Goal OS 地基 162 +### P2 — 搭 Goal OS 地基 ✅
163 163
164 -#### 6. Goal 实体  
165 -新建 `goals` 表:  
166 -```sql  
167 -goals(id, text, status, deadline, parentGoalId, personId, createdAt, updatedAt)  
168 -```  
169 -- `status`:active / paused / done / cancelled  
170 -- `parentGoalId`:支持树形分解(创业 → 融资 → 找 VC → 约张总)  
171 -- `personId`:与哪个人相关  
172 -  
173 -新增 GoalScreen,显示目标树 + 进度 + 关联 Followup + 关联 Person。 164 +#### 6. Goal 实体 ✅(2026-06-11 完成)
  165 +`goals` 表(`status` active/paused/done/cancelled,`deadline`,`parentGoalId` 树形,`personId`);`GoalScreen`(进行中/已完成分组,状态切换,删除,新建对话框含截止日期+关联人物);Room v24 MIGRATION;`GoalDao`;`PhotologViewModel.createGoal/updateGoalStatus/deleteGoal`;`LifelogTools` `create_goal/get_goals/update_goal_status`;`ContactsTab` 「🎯 目标」入口按钮。
174 166
175 -#### 7. Followup → Goal 挂载  
176 -`followups` 表加 `goalId`,从录音里抽出的 TODO 自动关联到进行中的目标。 167 +#### 7. Followup → Goal 挂载 ✅(2026-06-11 完成)
  168 +`followups.goalId` 外键;`FollowupDao.updateGoal/observeByGoal`;`FollowupsScreen` 每条待办显示「🎯 目标名」标签 + 「🎯」按钮弹出目标选择器(可绑定/解绑);`PhotologViewModel.linkFollowupToGoal`;`deleteGoal` 自动解绑挂载其下的待跟进;`LifelogTools` `link_followup_to_goal`。
177 169
178 -#### 8. AI Goal 工具  
179 -- `create_goal(text, deadline, parentGoalId)`  
180 -- `get_goals(status)`  
181 -- `link_followup_to_goal(followupId, goalId)` 170 +#### 8. AI Goal 工具 ✅(随 #6 一同完成)
  171 +- `create_goal(text, deadline, parent_goal_id, person_name, notes)`
  172 +- `get_goals(status=active|paused|done|cancelled|all)`
  173 +- `link_followup_to_goal(goal_id, followup_id 或 followup_text)`
  174 +- `update_goal_status(goal_id, status)`
  175 +- REACT_SYSTEM_PROMPT 已加 ㉓–㉖ 四个工具说明
182 176
183 --- 177 ---
184 178
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt
@@ -57,7 +57,11 @@ import androidx.compose.ui.Modifier @@ -57,7 +57,11 @@ import androidx.compose.ui.Modifier
57 import androidx.compose.foundation.text.selection.SelectionContainer 57 import androidx.compose.foundation.text.selection.SelectionContainer
58 import androidx.compose.ui.platform.LocalClipboardManager 58 import androidx.compose.ui.platform.LocalClipboardManager
59 import androidx.compose.ui.platform.LocalContext 59 import androidx.compose.ui.platform.LocalContext
  60 +import androidx.compose.ui.platform.LocalSoftwareKeyboardController
60 import androidx.compose.ui.text.AnnotatedString 61 import androidx.compose.ui.text.AnnotatedString
  62 +import androidx.compose.ui.text.input.ImeAction
  63 +import androidx.compose.foundation.text.KeyboardActions
  64 +import androidx.compose.foundation.text.KeyboardOptions
61 import androidx.compose.ui.text.style.TextOverflow 65 import androidx.compose.ui.text.style.TextOverflow
62 import androidx.compose.ui.unit.dp 66 import androidx.compose.ui.unit.dp
63 import com.lemon.lifelog.chat.ChatConfig 67 import com.lemon.lifelog.chat.ChatConfig
@@ -71,6 +75,7 @@ import kotlinx.coroutines.launch @@ -71,6 +75,7 @@ import kotlinx.coroutines.launch
71 internal fun ChatTab(repo: Repository) { 75 internal fun ChatTab(repo: Repository) {
72 val ctx = LocalContext.current 76 val ctx = LocalContext.current
73 val scope = rememberCoroutineScope() 77 val scope = rememberCoroutineScope()
  78 + val keyboardController = LocalSoftwareKeyboardController.current
74 // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。 79 // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。
75 val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel() 80 val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
76 81
@@ -257,8 +262,18 @@ internal fun ChatTab(repo: Repository) { @@ -257,8 +262,18 @@ internal fun ChatTab(repo: Repository) {
257 modifier = Modifier.weight(1f), 262 modifier = Modifier.weight(1f),
258 placeholder = { Text("发消息给 ${selectedTarget.label}…") }, 263 placeholder = { Text("发消息给 ${selectedTarget.label}…") },
259 maxLines = 5, 264 maxLines = 5,
  265 + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
  266 + keyboardActions = KeyboardActions(onSend = {
  267 + if (vm.input.isNotBlank() && !vm.streaming) {
  268 + vm.send()
  269 + keyboardController?.hide()
  270 + }
  271 + }),
260 ) 272 )
261 - IconButton(onClick = { vm.send() }, enabled = vm.input.isNotBlank() && !vm.streaming) { 273 + IconButton(
  274 + onClick = { vm.send(); keyboardController?.hide() },
  275 + enabled = vm.input.isNotBlank() && !vm.streaming,
  276 + ) {
262 if (vm.streaming) { 277 if (vm.streaming) {
263 CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) 278 CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp)
264 } else { 279 } else {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
11 import androidx.compose.foundation.lazy.items 11 import androidx.compose.foundation.lazy.items
12 import androidx.compose.material.icons.Icons 12 import androidx.compose.material.icons.Icons
13 import androidx.compose.material.icons.automirrored.filled.ArrowBack 13 import androidx.compose.material.icons.automirrored.filled.ArrowBack
  14 +import androidx.compose.material3.AlertDialog
14 import androidx.compose.material3.Checkbox 15 import androidx.compose.material3.Checkbox
15 import androidx.compose.material3.ExperimentalMaterial3Api 16 import androidx.compose.material3.ExperimentalMaterial3Api
16 import androidx.compose.material3.HorizontalDivider 17 import androidx.compose.material3.HorizontalDivider
@@ -23,10 +24,15 @@ import androidx.compose.material3.TopAppBar @@ -23,10 +24,15 @@ import androidx.compose.material3.TopAppBar
23 import androidx.compose.runtime.Composable 24 import androidx.compose.runtime.Composable
24 import androidx.compose.runtime.collectAsState 25 import androidx.compose.runtime.collectAsState
25 import androidx.compose.runtime.getValue 26 import androidx.compose.runtime.getValue
  27 +import androidx.compose.runtime.mutableStateOf
  28 +import androidx.compose.runtime.remember
  29 +import androidx.compose.runtime.setValue
26 import androidx.compose.ui.Alignment 30 import androidx.compose.ui.Alignment
27 import androidx.compose.ui.Modifier 31 import androidx.compose.ui.Modifier
28 import androidx.compose.ui.text.style.TextDecoration 32 import androidx.compose.ui.text.style.TextDecoration
29 import androidx.compose.ui.unit.dp 33 import androidx.compose.ui.unit.dp
  34 +import com.lemon.lifelog.data.FollowupRow
  35 +import com.lemon.lifelog.data.Goal
30 import java.text.SimpleDateFormat 36 import java.text.SimpleDateFormat
31 import java.util.Date 37 import java.util.Date
32 import java.util.Locale 38 import java.util.Locale
@@ -34,13 +40,18 @@ import java.util.Locale @@ -34,13 +40,18 @@ import java.util.Locale
34 private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) 40 private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
35 41
36 /** 42 /**
37 - * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删。点「扫描」重新抽。 43 + * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删,可绑定到目标(🎯)。
38 */ 44 */
39 @OptIn(ExperimentalMaterial3Api::class) 45 @OptIn(ExperimentalMaterial3Api::class)
40 @Composable 46 @Composable
41 internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { 47 internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) {
42 val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) 48 val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList())
  49 + val goals by vm.repo.goals.observeAll().collectAsState(initial = emptyList())
43 val scanning by vm.followupScanning.collectAsState() 50 val scanning by vm.followupScanning.collectAsState()
  51 + val goalById = remember(goals) { goals.associateBy { it.id } }
  52 +
  53 + // 当前正在选择目标的 followupId
  54 + var goalPickerForId by remember { mutableStateOf<Long?>(null) }
44 55
45 Column(Modifier.fillMaxSize()) { 56 Column(Modifier.fillMaxSize()) {
46 TopAppBar( 57 TopAppBar(
@@ -65,29 +76,117 @@ internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -&gt; Unit) { @@ -65,29 +76,117 @@ internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -&gt; Unit) {
65 } else { 76 } else {
66 LazyColumn(Modifier.fillMaxSize()) { 77 LazyColumn(Modifier.fillMaxSize()) {
67 items(items, key = { it.id }) { f -> 78 items(items, key = { it.id }) { f ->
68 - Row(  
69 - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),  
70 - verticalAlignment = Alignment.CenterVertically,  
71 - horizontalArrangement = Arrangement.spacedBy(6.dp), 79 + FollowupItem(
  80 + f = f,
  81 + goalName = f.goalId?.let { goalById[it]?.text },
  82 + goalsAvailable = goals.isNotEmpty(),
  83 + vm = vm,
  84 + onOpenGoalPicker = { goalPickerForId = f.id },
  85 + )
  86 + }
  87 + }
  88 + }
  89 + }
  90 +
  91 + goalPickerForId?.let { fid ->
  92 + val f = items.find { it.id == fid }
  93 + if (f != null) {
  94 + GoalPickerDialog(
  95 + followup = f,
  96 + goals = goals,
  97 + onPick = { goalId -> vm.linkFollowupToGoal(fid, goalId); goalPickerForId = null },
  98 + onDismiss = { goalPickerForId = null },
  99 + )
  100 + }
  101 + }
  102 +}
  103 +
  104 +@Composable
  105 +private fun FollowupItem(
  106 + f: FollowupRow,
  107 + goalName: String?,
  108 + goalsAvailable: Boolean,
  109 + vm: PhotologViewModel,
  110 + onOpenGoalPicker: () -> Unit,
  111 +) {
  112 + Row(
  113 + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
  114 + verticalAlignment = Alignment.CenterVertically,
  115 + horizontalArrangement = Arrangement.spacedBy(6.dp),
  116 + ) {
  117 + Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) })
  118 + Column(Modifier.weight(1f)) {
  119 + Text(
  120 + f.text,
  121 + style = MaterialTheme.typography.bodyLarge,
  122 + textDecoration = if (f.done) TextDecoration.LineThrough else null,
  123 + color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
  124 + )
  125 + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
  126 + Text(
  127 + (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)),
  128 + style = MaterialTheme.typography.labelSmall,
  129 + )
  130 + if (goalsAvailable) {
  131 + val goalLabel = if (goalName != null) "🎯 ${goalName.take(12)}${if (goalName.length > 12) "…" else ""}" else "🎯"
  132 + TextButton(
  133 + onClick = onOpenGoalPicker,
  134 + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp),
72 ) { 135 ) {
73 - Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) })  
74 - Column(Modifier.weight(1f)) {  
75 - Text(  
76 - f.text,  
77 - style = MaterialTheme.typography.bodyLarge,  
78 - textDecoration = if (f.done) TextDecoration.LineThrough else null,  
79 - color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,  
80 - )  
81 - Text(  
82 - (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)),  
83 - style = MaterialTheme.typography.labelSmall,  
84 - )  
85 - }  
86 - IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") } 136 + Text(
  137 + goalLabel,
  138 + style = MaterialTheme.typography.labelSmall,
  139 + color = if (goalName != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
  140 + )
87 } 141 }
88 - HorizontalDivider()  
89 } 142 }
90 } 143 }
91 } 144 }
  145 + IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") }
92 } 146 }
  147 + HorizontalDivider()
  148 +}
  149 +
  150 +@Composable
  151 +private fun GoalPickerDialog(
  152 + followup: FollowupRow,
  153 + goals: List<Goal>,
  154 + onPick: (Long?) -> Unit,
  155 + onDismiss: () -> Unit,
  156 +) {
  157 + AlertDialog(
  158 + onDismissRequest = onDismiss,
  159 + title = { Text("绑定到目标") },
  160 + text = {
  161 + LazyColumn {
  162 + item {
  163 + TextButton(
  164 + onClick = { onPick(null) },
  165 + modifier = Modifier.fillMaxWidth(),
  166 + ) {
  167 + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
  168 + Text(if (followup.goalId == null) "✓ " else " ")
  169 + Text("不挂载目标", style = MaterialTheme.typography.bodyMedium)
  170 + }
  171 + }
  172 + }
  173 + items(goals, key = { it.id }) { g ->
  174 + TextButton(
  175 + onClick = { onPick(g.id) },
  176 + modifier = Modifier.fillMaxWidth(),
  177 + ) {
  178 + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
  179 + Text(if (followup.goalId == g.id) "✓ " else " ")
  180 + Column {
  181 + Text(g.text, style = MaterialTheme.typography.bodyMedium)
  182 + g.deadline?.let { Text("📅 $it", style = MaterialTheme.typography.labelSmall) }
  183 + }
  184 + }
  185 + }
  186 + }
  187 + }
  188 + },
  189 + confirmButton = {},
  190 + dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } },
  191 + )
93 } 192 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt 0 → 100644
  1 +package com.lemon.lifelog
  2 +
  3 +import androidx.compose.foundation.layout.Arrangement
  4 +import androidx.compose.foundation.layout.Box
  5 +import androidx.compose.foundation.layout.Column
  6 +import androidx.compose.foundation.layout.Row
  7 +import androidx.compose.foundation.layout.fillMaxSize
  8 +import androidx.compose.foundation.layout.fillMaxWidth
  9 +import androidx.compose.foundation.layout.padding
  10 +import androidx.compose.foundation.lazy.LazyColumn
  11 +import androidx.compose.foundation.lazy.items
  12 +import androidx.compose.foundation.shape.RoundedCornerShape
  13 +import androidx.compose.material.icons.Icons
  14 +import androidx.compose.material.icons.automirrored.filled.ArrowBack
  15 +import androidx.compose.material3.AlertDialog
  16 +import androidx.compose.material3.DropdownMenu
  17 +import androidx.compose.material3.DropdownMenuItem
  18 +import androidx.compose.material3.ExperimentalMaterial3Api
  19 +import androidx.compose.material3.HorizontalDivider
  20 +import androidx.compose.material3.Icon
  21 +import androidx.compose.material3.IconButton
  22 +import androidx.compose.material3.MaterialTheme
  23 +import androidx.compose.material3.OutlinedButton
  24 +import androidx.compose.material3.OutlinedTextField
  25 +import androidx.compose.material3.Surface
  26 +import androidx.compose.material3.Text
  27 +import androidx.compose.material3.TextButton
  28 +import androidx.compose.material3.TopAppBar
  29 +import androidx.compose.runtime.Composable
  30 +import androidx.compose.runtime.collectAsState
  31 +import androidx.compose.runtime.getValue
  32 +import androidx.compose.runtime.mutableStateOf
  33 +import androidx.compose.runtime.remember
  34 +import androidx.compose.runtime.setValue
  35 +import androidx.compose.ui.Alignment
  36 +import androidx.compose.ui.Modifier
  37 +import androidx.compose.ui.graphics.Color
  38 +import androidx.compose.ui.text.style.TextDecoration
  39 +import androidx.compose.ui.unit.dp
  40 +import com.lemon.lifelog.data.Goal
  41 +import com.lemon.lifelog.data.Person
  42 +
  43 +@OptIn(ExperimentalMaterial3Api::class)
  44 +@Composable
  45 +internal fun GoalScreen(vm: PhotologViewModel, onBack: () -> Unit) {
  46 + val goals by vm.repo.goals.observeAll().collectAsState(initial = emptyList())
  47 + val allFollowups by vm.repo.followups.observeAll().collectAsState(initial = emptyList())
  48 + val persons by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
  49 + var showCreate by remember { mutableStateOf(false) }
  50 +
  51 + val openCountByGoal = remember(allFollowups) {
  52 + allFollowups.filter { !it.done && it.goalId != null }
  53 + .groupBy { it.goalId!! }
  54 + .mapValues { it.value.size }
  55 + }
  56 + val personById = remember(persons) { persons.associateBy { it.id } }
  57 + val active = remember(goals) { goals.filter { it.status == "active" || it.status == "paused" } }
  58 + val completed = remember(goals) { goals.filter { it.status == "done" || it.status == "cancelled" } }
  59 +
  60 + Column(Modifier.fillMaxSize()) {
  61 + TopAppBar(
  62 + title = { Text("目标") },
  63 + navigationIcon = {
  64 + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") }
  65 + },
  66 + actions = { TextButton(onClick = { showCreate = true }) { Text("新建") } },
  67 + )
  68 +
  69 + if (goals.isEmpty()) {
  70 + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
  71 + Text(
  72 + "还没有目标。\n点右上「新建」创建目标,再把「待跟进」事项挂到目标上追踪进展。",
  73 + style = MaterialTheme.typography.bodyMedium,
  74 + )
  75 + }
  76 + } else {
  77 + LazyColumn(Modifier.fillMaxSize()) {
  78 + if (active.isNotEmpty()) {
  79 + item { GoalSectionHeader("进行中(${active.size})") }
  80 + items(active, key = { it.id }) { goal ->
  81 + GoalCard(
  82 + goal = goal,
  83 + personName = goal.personId?.let { personById[it]?.name },
  84 + openFollowups = openCountByGoal[goal.id] ?: 0,
  85 + vm = vm,
  86 + )
  87 + }
  88 + }
  89 + if (completed.isNotEmpty()) {
  90 + item { GoalSectionHeader("已完成 / 取消(${completed.size})") }
  91 + items(completed, key = { it.id }) { goal ->
  92 + GoalCard(
  93 + goal = goal,
  94 + personName = goal.personId?.let { personById[it]?.name },
  95 + openFollowups = openCountByGoal[goal.id] ?: 0,
  96 + vm = vm,
  97 + )
  98 + }
  99 + }
  100 + }
  101 + }
  102 + }
  103 +
  104 + if (showCreate) {
  105 + CreateGoalDialog(
  106 + persons = persons,
  107 + onCreate = { text, deadline, personId ->
  108 + vm.createGoal(text, deadline, personId = personId)
  109 + showCreate = false
  110 + },
  111 + onDismiss = { showCreate = false },
  112 + )
  113 + }
  114 +}
  115 +
  116 +@Composable
  117 +private fun GoalSectionHeader(title: String) {
  118 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
  119 + Text(title, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
  120 + }
  121 +}
  122 +
  123 +@Composable
  124 +private fun GoalCard(goal: Goal, personName: String?, openFollowups: Int, vm: PhotologViewModel) {
  125 + val isDone = goal.status == "done" || goal.status == "cancelled"
  126 + Row(
  127 + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
  128 + verticalAlignment = Alignment.CenterVertically,
  129 + horizontalArrangement = Arrangement.spacedBy(4.dp),
  130 + ) {
  131 + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
  132 + Text(
  133 + goal.text,
  134 + style = MaterialTheme.typography.bodyLarge,
  135 + textDecoration = if (isDone) TextDecoration.LineThrough else null,
  136 + color = if (isDone) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
  137 + )
  138 + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
  139 + GoalStatusChip(goal.status)
  140 + goal.deadline?.let { Text("📅 $it", style = MaterialTheme.typography.labelSmall) }
  141 + personName?.let { Text("👤 $it", style = MaterialTheme.typography.labelSmall) }
  142 + if (openFollowups > 0) {
  143 + Text(
  144 + "✅ $openFollowups 待办",
  145 + style = MaterialTheme.typography.labelSmall,
  146 + color = MaterialTheme.colorScheme.primary,
  147 + )
  148 + }
  149 + }
  150 + goal.notes?.takeIf { it.isNotBlank() }?.let {
  151 + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
  152 + }
  153 + }
  154 + if (isDone) {
  155 + IconButton(onClick = { vm.updateGoalStatus(goal.id, "active") }) { Text("🔄") }
  156 + } else {
  157 + IconButton(onClick = { vm.updateGoalStatus(goal.id, "done") }) { Text("✓") }
  158 + }
  159 + IconButton(onClick = { vm.deleteGoal(goal.id) }) { Text("✕") }
  160 + }
  161 + HorizontalDivider()
  162 +}
  163 +
  164 +@Composable
  165 +private fun GoalStatusChip(status: String) {
  166 + val (label, color) = when (status) {
  167 + "active" -> "进行中" to MaterialTheme.colorScheme.primary
  168 + "paused" -> "暂停" to MaterialTheme.colorScheme.secondary
  169 + "done" -> "完成" to Color(0xFF4CAF50)
  170 + "cancelled" -> "取消" to MaterialTheme.colorScheme.error
  171 + else -> status to MaterialTheme.colorScheme.onSurface
  172 + }
  173 + Surface(shape = RoundedCornerShape(8.dp), color = color.copy(alpha = 0.15f)) {
  174 + Text(
  175 + label,
  176 + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
  177 + style = MaterialTheme.typography.labelSmall,
  178 + color = color,
  179 + )
  180 + }
  181 +}
  182 +
  183 +@Composable
  184 +private fun CreateGoalDialog(
  185 + persons: List<Person>,
  186 + onCreate: (text: String, deadline: String?, personId: Long?) -> Unit,
  187 + onDismiss: () -> Unit,
  188 +) {
  189 + var text by remember { mutableStateOf("") }
  190 + var deadline by remember { mutableStateOf("") }
  191 + var personMenuOpen by remember { mutableStateOf(false) }
  192 + var selectedPersonId by remember { mutableStateOf<Long?>(null) }
  193 + val selectedName = persons.find { it.id == selectedPersonId }?.name ?: "不关联人物"
  194 +
  195 + AlertDialog(
  196 + onDismissRequest = onDismiss,
  197 + title = { Text("新建目标") },
  198 + text = {
  199 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
  200 + OutlinedTextField(
  201 + value = text,
  202 + onValueChange = { text = it },
  203 + label = { Text("目标内容") },
  204 + modifier = Modifier.fillMaxWidth(),
  205 + minLines = 2,
  206 + )
  207 + OutlinedTextField(
  208 + value = deadline,
  209 + onValueChange = { deadline = it },
  210 + label = { Text("截止日期(选填,如 2026-09-01)") },
  211 + modifier = Modifier.fillMaxWidth(),
  212 + singleLine = true,
  213 + )
  214 + if (persons.isNotEmpty()) {
  215 + Box {
  216 + OutlinedButton(onClick = { personMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
  217 + Text("👤 $selectedName")
  218 + }
  219 + DropdownMenu(expanded = personMenuOpen, onDismissRequest = { personMenuOpen = false }) {
  220 + DropdownMenuItem(
  221 + text = { Text("不关联人物") },
  222 + onClick = { selectedPersonId = null; personMenuOpen = false },
  223 + )
  224 + persons.forEach { p ->
  225 + DropdownMenuItem(
  226 + text = { Text(p.name) },
  227 + onClick = { selectedPersonId = p.id; personMenuOpen = false },
  228 + )
  229 + }
  230 + }
  231 + }
  232 + }
  233 + }
  234 + },
  235 + confirmButton = {
  236 + TextButton(
  237 + enabled = text.isNotBlank(),
  238 + onClick = { onCreate(text.trim(), deadline.trim().ifBlank { null }, selectedPersonId) },
  239 + ) { Text("创建") }
  240 + },
  241 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  242 + )
  243 +}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -101,6 +101,7 @@ internal fun ContactsTab(repo: Repository) { @@ -101,6 +101,7 @@ internal fun ContactsTab(repo: Repository) {
101 var showCommunities by remember { mutableStateOf(false) } 101 var showCommunities by remember { mutableStateOf(false) }
102 var showFollowups by remember { mutableStateOf(false) } 102 var showFollowups by remember { mutableStateOf(false) }
103 var showTopics by remember { mutableStateOf(false) } 103 var showTopics by remember { mutableStateOf(false) }
  104 + var showGoals by remember { mutableStateOf(false) }
104 var mergeForId by remember { mutableStateOf<Long?>(null) } 105 var mergeForId by remember { mutableStateOf<Long?>(null) }
105 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 106 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。
106 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) 107 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
@@ -124,6 +125,7 @@ internal fun ContactsTab(repo: Repository) { @@ -124,6 +125,7 @@ internal fun ContactsTab(repo: Repository) {
124 ) 125 )
125 showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) 126 showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false })
126 showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false }) 127 showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false })
  128 + showGoals -> GoalScreen(vm = vm, onBack = { showGoals = false })
127 showPicker -> WeChatPickerScreen( 129 showPicker -> WeChatPickerScreen(
128 vm = vm, 130 vm = vm,
129 onBack = { showPicker = false }, 131 onBack = { showPicker = false },
@@ -136,6 +138,7 @@ internal fun ContactsTab(repo: Repository) { @@ -136,6 +138,7 @@ internal fun ContactsTab(repo: Repository) {
136 onOpenCommunities = { showCommunities = true }, 138 onOpenCommunities = { showCommunities = true },
137 onOpenFollowups = { showFollowups = true }, 139 onOpenFollowups = { showFollowups = true },
138 onOpenTopics = { showTopics = true }, 140 onOpenTopics = { showTopics = true },
  141 + onOpenGoals = { showGoals = true },
139 ) 142 )
140 } 143 }
141 detailPhotoId?.let { id -> 144 detailPhotoId?.let { id ->
@@ -668,7 +671,7 @@ private fun TagFaceDialog( @@ -668,7 +671,7 @@ private fun TagFaceDialog(
668 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ 671 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */
669 @OptIn(ExperimentalFoundationApi::class) 672 @OptIn(ExperimentalFoundationApi::class)
670 @Composable 673 @Composable
671 -private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { 674 +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit, onOpenGoals: () -> Unit) {
672 var query by remember { mutableStateOf("") } 675 var query by remember { mutableStateOf("") }
673 676
674 // 真实最近联系时间:微信最后一条消息 + 最后一次被提到,取 MAX 677 // 真实最近联系时间:微信最后一条消息 + 最后一次被提到,取 MAX
@@ -733,6 +736,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -733,6 +736,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
733 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } 736 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") }
734 OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } 737 OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") }
735 OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") } 738 OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") }
  739 + OutlinedButton(onClick = onOpenGoals) { Text("🎯 目标") }
736 } 740 }
737 HorizontalDivider() 741 HorizontalDivider()
738 if (persons.isEmpty()) { 742 if (persons.isEmpty()) {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -225,6 +225,31 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -225,6 +225,31 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
225 } 225 }
226 fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } 226 fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) }
227 fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } 227 fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) }
  228 + fun linkFollowupToGoal(followupId: Long, goalId: Long?) = viewModelScope.launch { repo.followups.updateGoal(followupId, goalId) }
  229 +
  230 + // ── Goal OS ──────────────────────────────────────────────────────────────────
  231 + fun createGoal(text: String, deadline: String? = null, parentGoalId: Long? = null, personId: Long? = null) {
  232 + viewModelScope.launch {
  233 + repo.goals.insert(
  234 + com.lemon.lifelog.data.Goal(
  235 + text = text,
  236 + deadline = deadline?.takeIf { it.isNotBlank() },
  237 + parentGoalId = parentGoalId,
  238 + personId = personId,
  239 + ),
  240 + )
  241 + }
  242 + }
  243 + fun updateGoalStatus(id: Long, status: String) = viewModelScope.launch {
  244 + repo.goals.updateStatus(id, status, System.currentTimeMillis())
  245 + }
  246 + fun deleteGoal(id: Long) = viewModelScope.launch {
  247 + repo.goals.delete(id)
  248 + // 解绑挂在该目标下的所有待跟进
  249 + repo.followups.all().filter { it.goalId == id }.forEach { f ->
  250 + repo.followups.updateGoal(f.id, null)
  251 + }
  252 + }
228 253
229 /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */ 254 /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */
230 private val _topicScanning = MutableStateFlow(false) 255 private val _topicScanning = MutableStateFlow(false)