Commit bbac773f7b88054d6ed55425be1ca348ba6247d9
1 parent
92a426c8
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>
Showing
6 changed files
with
419 additions
and
39 deletions
lifelog-recorder/LIFEOS_ROADMAP.md
| ... | ... | @@ -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 | 57 | import androidx.compose.foundation.text.selection.SelectionContainer |
| 58 | 58 | import androidx.compose.ui.platform.LocalClipboardManager |
| 59 | 59 | import androidx.compose.ui.platform.LocalContext |
| 60 | +import androidx.compose.ui.platform.LocalSoftwareKeyboardController | |
| 60 | 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 | 65 | import androidx.compose.ui.text.style.TextOverflow |
| 62 | 66 | import androidx.compose.ui.unit.dp |
| 63 | 67 | import com.lemon.lifelog.chat.ChatConfig |
| ... | ... | @@ -71,6 +75,7 @@ import kotlinx.coroutines.launch |
| 71 | 75 | internal fun ChatTab(repo: Repository) { |
| 72 | 76 | val ctx = LocalContext.current |
| 73 | 77 | val scope = rememberCoroutineScope() |
| 78 | + val keyboardController = LocalSoftwareKeyboardController.current | |
| 74 | 79 | // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。 |
| 75 | 80 | val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel() |
| 76 | 81 | |
| ... | ... | @@ -257,8 +262,18 @@ internal fun ChatTab(repo: Repository) { |
| 257 | 262 | modifier = Modifier.weight(1f), |
| 258 | 263 | placeholder = { Text("发消息给 ${selectedTarget.label}…") }, |
| 259 | 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 | 277 | if (vm.streaming) { |
| 263 | 278 | CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) |
| 264 | 279 | } else { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt
| ... | ... | @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn |
| 11 | 11 | import androidx.compose.foundation.lazy.items |
| 12 | 12 | import androidx.compose.material.icons.Icons |
| 13 | 13 | import androidx.compose.material.icons.automirrored.filled.ArrowBack |
| 14 | +import androidx.compose.material3.AlertDialog | |
| 14 | 15 | import androidx.compose.material3.Checkbox |
| 15 | 16 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 16 | 17 | import androidx.compose.material3.HorizontalDivider |
| ... | ... | @@ -23,10 +24,15 @@ import androidx.compose.material3.TopAppBar |
| 23 | 24 | import androidx.compose.runtime.Composable |
| 24 | 25 | import androidx.compose.runtime.collectAsState |
| 25 | 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 | 30 | import androidx.compose.ui.Alignment |
| 27 | 31 | import androidx.compose.ui.Modifier |
| 28 | 32 | import androidx.compose.ui.text.style.TextDecoration |
| 29 | 33 | import androidx.compose.ui.unit.dp |
| 34 | +import com.lemon.lifelog.data.FollowupRow | |
| 35 | +import com.lemon.lifelog.data.Goal | |
| 30 | 36 | import java.text.SimpleDateFormat |
| 31 | 37 | import java.util.Date |
| 32 | 38 | import java.util.Locale |
| ... | ... | @@ -34,13 +40,18 @@ import java.util.Locale |
| 34 | 40 | private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 35 | 41 | |
| 36 | 42 | /** |
| 37 | - * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删。点「扫描」重新抽。 | |
| 43 | + * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删,可绑定到目标(🎯)。 | |
| 38 | 44 | */ |
| 39 | 45 | @OptIn(ExperimentalMaterial3Api::class) |
| 40 | 46 | @Composable |
| 41 | 47 | internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { |
| 42 | 48 | val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) |
| 49 | + val goals by vm.repo.goals.observeAll().collectAsState(initial = emptyList()) | |
| 43 | 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 | 56 | Column(Modifier.fillMaxSize()) { |
| 46 | 57 | TopAppBar( |
| ... | ... | @@ -65,29 +76,117 @@ internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { |
| 65 | 76 | } else { |
| 66 | 77 | LazyColumn(Modifier.fillMaxSize()) { |
| 67 | 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 | 101 | var showCommunities by remember { mutableStateOf(false) } |
| 102 | 102 | var showFollowups by remember { mutableStateOf(false) } |
| 103 | 103 | var showTopics by remember { mutableStateOf(false) } |
| 104 | + var showGoals by remember { mutableStateOf(false) } | |
| 104 | 105 | var mergeForId by remember { mutableStateOf<Long?>(null) } |
| 105 | 106 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 106 | 107 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| ... | ... | @@ -124,6 +125,7 @@ internal fun ContactsTab(repo: Repository) { |
| 124 | 125 | ) |
| 125 | 126 | showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) |
| 126 | 127 | showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false }) |
| 128 | + showGoals -> GoalScreen(vm = vm, onBack = { showGoals = false }) | |
| 127 | 129 | showPicker -> WeChatPickerScreen( |
| 128 | 130 | vm = vm, |
| 129 | 131 | onBack = { showPicker = false }, |
| ... | ... | @@ -136,6 +138,7 @@ internal fun ContactsTab(repo: Repository) { |
| 136 | 138 | onOpenCommunities = { showCommunities = true }, |
| 137 | 139 | onOpenFollowups = { showFollowups = true }, |
| 138 | 140 | onOpenTopics = { showTopics = true }, |
| 141 | + onOpenGoals = { showGoals = true }, | |
| 139 | 142 | ) |
| 140 | 143 | } |
| 141 | 144 | detailPhotoId?.let { id -> |
| ... | ... | @@ -668,7 +671,7 @@ private fun TagFaceDialog( |
| 668 | 671 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ |
| 669 | 672 | @OptIn(ExperimentalFoundationApi::class) |
| 670 | 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 | 675 | var query by remember { mutableStateOf("") } |
| 673 | 676 | |
| 674 | 677 | // 真实最近联系时间:微信最后一条消息 + 最后一次被提到,取 MAX |
| ... | ... | @@ -733,6 +736,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 733 | 736 | OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } |
| 734 | 737 | OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } |
| 735 | 738 | OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") } |
| 739 | + OutlinedButton(onClick = onOpenGoals) { Text("🎯 目标") } | |
| 736 | 740 | } |
| 737 | 741 | HorizontalDivider() |
| 738 | 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 | 225 | } |
| 226 | 226 | fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } |
| 227 | 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 | 254 | /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */ |
| 230 | 255 | private val _topicScanning = MutableStateFlow(false) | ... | ... |