Commit 11c471babe73b27e04f47acda0a697ff884ec286
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>
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,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: () -> Unit) { | @@ -65,29 +76,117 @@ internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> 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<Person>, onOpen: (L | @@ -733,6 +736,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, 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) |