From 11c471babe73b27e04f47acda0a697ff884ec286 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 11 Jun 2026 01:04:26 +0800 Subject: [PATCH] feat(goal): P2 #6/#7 — GoalScreen UI + Followup 绑定目标 --- lifelog-recorder/LIFEOS_ROADMAP.md | 28 +++++++++++----------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt | 17 ++++++++++++++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt | 243 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 6 +++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 25 +++++++++++++++++++++++++ 6 files changed, 419 insertions(+), 39 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt diff --git a/lifelog-recorder/LIFEOS_ROADMAP.md b/lifelog-recorder/LIFEOS_ROADMAP.md index 7faec6c..e834a79 100644 --- a/lifelog-recorder/LIFEOS_ROADMAP.md +++ b/lifelog-recorder/LIFEOS_ROADMAP.md @@ -159,26 +159,20 @@ AI 工具加 `find_relevant_persons(goal: String)`: --- -### P2 — 搭 Goal OS 地基 +### P2 — 搭 Goal OS 地基 ✅ -#### 6. Goal 实体 -新建 `goals` 表: -```sql -goals(id, text, status, deadline, parentGoalId, personId, createdAt, updatedAt) -``` -- `status`:active / paused / done / cancelled -- `parentGoalId`:支持树形分解(创业 → 融资 → 找 VC → 约张总) -- `personId`:与哪个人相关 - -新增 GoalScreen,显示目标树 + 进度 + 关联 Followup + 关联 Person。 +#### 6. Goal 实体 ✅(2026-06-11 完成) +`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` 「🎯 目标」入口按钮。 -#### 7. Followup → Goal 挂载 -`followups` 表加 `goalId`,从录音里抽出的 TODO 自动关联到进行中的目标。 +#### 7. Followup → Goal 挂载 ✅(2026-06-11 完成) +`followups.goalId` 外键;`FollowupDao.updateGoal/observeByGoal`;`FollowupsScreen` 每条待办显示「🎯 目标名」标签 + 「🎯」按钮弹出目标选择器(可绑定/解绑);`PhotologViewModel.linkFollowupToGoal`;`deleteGoal` 自动解绑挂载其下的待跟进;`LifelogTools` `link_followup_to_goal`。 -#### 8. AI Goal 工具 -- `create_goal(text, deadline, parentGoalId)` -- `get_goals(status)` -- `link_followup_to_goal(followupId, goalId)` +#### 8. AI Goal 工具 ✅(随 #6 一同完成) +- `create_goal(text, deadline, parent_goal_id, person_name, notes)` +- `get_goals(status=active|paused|done|cancelled|all)` +- `link_followup_to_goal(goal_id, followup_id 或 followup_text)` +- `update_goal_status(goal_id, status)` +- REACT_SYSTEM_PROMPT 已加 ㉓–㉖ 四个工具说明 --- diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt index 35c0ebb..7b5a538 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt @@ -57,7 +57,11 @@ import androidx.compose.ui.Modifier import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lemon.lifelog.chat.ChatConfig @@ -71,6 +75,7 @@ import kotlinx.coroutines.launch internal fun ChatTab(repo: Repository) { val ctx = LocalContext.current val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。 val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel() @@ -257,8 +262,18 @@ internal fun ChatTab(repo: Repository) { modifier = Modifier.weight(1f), placeholder = { Text("发消息给 ${selectedTarget.label}…") }, maxLines = 5, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (vm.input.isNotBlank() && !vm.streaming) { + vm.send() + keyboardController?.hide() + } + }), ) - IconButton(onClick = { vm.send() }, enabled = vm.input.isNotBlank() && !vm.streaming) { + IconButton( + onClick = { vm.send(); keyboardController?.hide() }, + enabled = vm.input.isNotBlank() && !vm.streaming, + ) { if (vm.streaming) { CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) } else { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt index 39d01a9..65fc68d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -23,10 +24,15 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import com.lemon.lifelog.data.FollowupRow +import com.lemon.lifelog.data.Goal import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -34,13 +40,18 @@ import java.util.Locale private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) /** - * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删。点「扫描」重新抽。 + * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删,可绑定到目标(🎯)。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) + val goals by vm.repo.goals.observeAll().collectAsState(initial = emptyList()) val scanning by vm.followupScanning.collectAsState() + val goalById = remember(goals) { goals.associateBy { it.id } } + + // 当前正在选择目标的 followupId + var goalPickerForId by remember { mutableStateOf(null) } Column(Modifier.fillMaxSize()) { TopAppBar( @@ -65,29 +76,117 @@ internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { } else { LazyColumn(Modifier.fillMaxSize()) { items(items, key = { it.id }) { f -> - Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + FollowupItem( + f = f, + goalName = f.goalId?.let { goalById[it]?.text }, + goalsAvailable = goals.isNotEmpty(), + vm = vm, + onOpenGoalPicker = { goalPickerForId = f.id }, + ) + } + } + } + } + + goalPickerForId?.let { fid -> + val f = items.find { it.id == fid } + if (f != null) { + GoalPickerDialog( + followup = f, + goals = goals, + onPick = { goalId -> vm.linkFollowupToGoal(fid, goalId); goalPickerForId = null }, + onDismiss = { goalPickerForId = null }, + ) + } + } +} + +@Composable +private fun FollowupItem( + f: FollowupRow, + goalName: String?, + goalsAvailable: Boolean, + vm: PhotologViewModel, + onOpenGoalPicker: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) }) + Column(Modifier.weight(1f)) { + Text( + f.text, + style = MaterialTheme.typography.bodyLarge, + textDecoration = if (f.done) TextDecoration.LineThrough else null, + color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)), + style = MaterialTheme.typography.labelSmall, + ) + if (goalsAvailable) { + val goalLabel = if (goalName != null) "🎯 ${goalName.take(12)}${if (goalName.length > 12) "…" else ""}" else "🎯" + TextButton( + onClick = onOpenGoalPicker, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp), ) { - Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) }) - Column(Modifier.weight(1f)) { - Text( - f.text, - style = MaterialTheme.typography.bodyLarge, - textDecoration = if (f.done) TextDecoration.LineThrough else null, - color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, - ) - Text( - (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)), - style = MaterialTheme.typography.labelSmall, - ) - } - IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") } + Text( + goalLabel, + style = MaterialTheme.typography.labelSmall, + color = if (goalName != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) } - HorizontalDivider() } } } + IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") } } + HorizontalDivider() +} + +@Composable +private fun GoalPickerDialog( + followup: FollowupRow, + goals: List, + onPick: (Long?) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("绑定到目标") }, + text = { + LazyColumn { + item { + TextButton( + onClick = { onPick(null) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(if (followup.goalId == null) "✓ " else " ") + Text("不挂载目标", style = MaterialTheme.typography.bodyMedium) + } + } + } + items(goals, key = { it.id }) { g -> + TextButton( + onClick = { onPick(g.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(if (followup.goalId == g.id) "✓ " else " ") + Column { + Text(g.text, style = MaterialTheme.typography.bodyMedium) + g.deadline?.let { Text("📅 $it", style = MaterialTheme.typography.labelSmall) } + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } }, + ) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt new file mode 100644 index 0000000..9c0828f --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/GoalScreen.kt @@ -0,0 +1,243 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.lemon.lifelog.data.Goal +import com.lemon.lifelog.data.Person + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun GoalScreen(vm: PhotologViewModel, onBack: () -> Unit) { + val goals by vm.repo.goals.observeAll().collectAsState(initial = emptyList()) + val allFollowups by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) + val persons by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) + var showCreate by remember { mutableStateOf(false) } + + val openCountByGoal = remember(allFollowups) { + allFollowups.filter { !it.done && it.goalId != null } + .groupBy { it.goalId!! } + .mapValues { it.value.size } + } + val personById = remember(persons) { persons.associateBy { it.id } } + val active = remember(goals) { goals.filter { it.status == "active" || it.status == "paused" } } + val completed = remember(goals) { goals.filter { it.status == "done" || it.status == "cancelled" } } + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("目标") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + actions = { TextButton(onClick = { showCreate = true }) { Text("新建") } }, + ) + + if (goals.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "还没有目标。\n点右上「新建」创建目标,再把「待跟进」事项挂到目标上追踪进展。", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + if (active.isNotEmpty()) { + item { GoalSectionHeader("进行中(${active.size})") } + items(active, key = { it.id }) { goal -> + GoalCard( + goal = goal, + personName = goal.personId?.let { personById[it]?.name }, + openFollowups = openCountByGoal[goal.id] ?: 0, + vm = vm, + ) + } + } + if (completed.isNotEmpty()) { + item { GoalSectionHeader("已完成 / 取消(${completed.size})") } + items(completed, key = { it.id }) { goal -> + GoalCard( + goal = goal, + personName = goal.personId?.let { personById[it]?.name }, + openFollowups = openCountByGoal[goal.id] ?: 0, + vm = vm, + ) + } + } + } + } + } + + if (showCreate) { + CreateGoalDialog( + persons = persons, + onCreate = { text, deadline, personId -> + vm.createGoal(text, deadline, personId = personId) + showCreate = false + }, + onDismiss = { showCreate = false }, + ) + } +} + +@Composable +private fun GoalSectionHeader(title: String) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } +} + +@Composable +private fun GoalCard(goal: Goal, personName: String?, openFollowups: Int, vm: PhotologViewModel) { + val isDone = goal.status == "done" || goal.status == "cancelled" + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + goal.text, + style = MaterialTheme.typography.bodyLarge, + textDecoration = if (isDone) TextDecoration.LineThrough else null, + color = if (isDone) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + GoalStatusChip(goal.status) + goal.deadline?.let { Text("📅 $it", style = MaterialTheme.typography.labelSmall) } + personName?.let { Text("👤 $it", style = MaterialTheme.typography.labelSmall) } + if (openFollowups > 0) { + Text( + "✅ $openFollowups 待办", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + goal.notes?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (isDone) { + IconButton(onClick = { vm.updateGoalStatus(goal.id, "active") }) { Text("🔄") } + } else { + IconButton(onClick = { vm.updateGoalStatus(goal.id, "done") }) { Text("✓") } + } + IconButton(onClick = { vm.deleteGoal(goal.id) }) { Text("✕") } + } + HorizontalDivider() +} + +@Composable +private fun GoalStatusChip(status: String) { + val (label, color) = when (status) { + "active" -> "进行中" to MaterialTheme.colorScheme.primary + "paused" -> "暂停" to MaterialTheme.colorScheme.secondary + "done" -> "完成" to Color(0xFF4CAF50) + "cancelled" -> "取消" to MaterialTheme.colorScheme.error + else -> status to MaterialTheme.colorScheme.onSurface + } + Surface(shape = RoundedCornerShape(8.dp), color = color.copy(alpha = 0.15f)) { + Text( + label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + ) + } +} + +@Composable +private fun CreateGoalDialog( + persons: List, + onCreate: (text: String, deadline: String?, personId: Long?) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf("") } + var deadline by remember { mutableStateOf("") } + var personMenuOpen by remember { mutableStateOf(false) } + var selectedPersonId by remember { mutableStateOf(null) } + val selectedName = persons.find { it.id == selectedPersonId }?.name ?: "不关联人物" + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("新建目标") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("目标内容") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + OutlinedTextField( + value = deadline, + onValueChange = { deadline = it }, + label = { Text("截止日期(选填,如 2026-09-01)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + if (persons.isNotEmpty()) { + Box { + OutlinedButton(onClick = { personMenuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text("👤 $selectedName") + } + DropdownMenu(expanded = personMenuOpen, onDismissRequest = { personMenuOpen = false }) { + DropdownMenuItem( + text = { Text("不关联人物") }, + onClick = { selectedPersonId = null; personMenuOpen = false }, + ) + persons.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { selectedPersonId = p.id; personMenuOpen = false }, + ) + } + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = text.isNotBlank(), + onClick = { onCreate(text.trim(), deadline.trim().ifBlank { null }, selectedPersonId) }, + ) { Text("创建") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 168bfe5..351fe00 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -101,6 +101,7 @@ internal fun ContactsTab(repo: Repository) { var showCommunities by remember { mutableStateOf(false) } var showFollowups by remember { mutableStateOf(false) } var showTopics by remember { mutableStateOf(false) } + var showGoals by remember { mutableStateOf(false) } var mergeForId by remember { mutableStateOf(null) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) @@ -124,6 +125,7 @@ internal fun ContactsTab(repo: Repository) { ) showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false }) + showGoals -> GoalScreen(vm = vm, onBack = { showGoals = false }) showPicker -> WeChatPickerScreen( vm = vm, onBack = { showPicker = false }, @@ -136,6 +138,7 @@ internal fun ContactsTab(repo: Repository) { onOpenCommunities = { showCommunities = true }, onOpenFollowups = { showFollowups = true }, onOpenTopics = { showTopics = true }, + onOpenGoals = { showGoals = true }, ) } detailPhotoId?.let { id -> @@ -668,7 +671,7 @@ private fun TagFaceDialog( /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit, onOpenGoals: () -> Unit) { var query by remember { mutableStateOf("") } // 真实最近联系时间:微信最后一条消息 + 最后一次被提到,取 MAX @@ -733,6 +736,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") } + OutlinedButton(onClick = onOpenGoals) { Text("🎯 目标") } } HorizontalDivider() if (persons.isEmpty()) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index feac349..6abac87 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -225,6 +225,31 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } + fun linkFollowupToGoal(followupId: Long, goalId: Long?) = viewModelScope.launch { repo.followups.updateGoal(followupId, goalId) } + + // ── Goal OS ────────────────────────────────────────────────────────────────── + fun createGoal(text: String, deadline: String? = null, parentGoalId: Long? = null, personId: Long? = null) { + viewModelScope.launch { + repo.goals.insert( + com.lemon.lifelog.data.Goal( + text = text, + deadline = deadline?.takeIf { it.isNotBlank() }, + parentGoalId = parentGoalId, + personId = personId, + ), + ) + } + } + fun updateGoalStatus(id: Long, status: String) = viewModelScope.launch { + repo.goals.updateStatus(id, status, System.currentTimeMillis()) + } + fun deleteGoal(id: Long) = viewModelScope.launch { + repo.goals.delete(id) + // 解绑挂在该目标下的所有待跟进 + repo.followups.all().filter { it.goalId == id }.forEach { f -> + repo.followups.updateGoal(f.id, null) + } + } /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */ private val _topicScanning = MutableStateFlow(false) -- libgit2 0.21.4