Commit 3bf1909fe0eb0607471edbba0507c53a8ada8af1
1 parent
906306c3
feat(lifelog/photolog): v0 UI — 相册 tab + 照片网格 + 人脸标注
- 新增「相册」tab(照片 / 人物 两子页) - 照片:Coil 网格 + 人脸数徽标;「+导入照片」走系统相册选择器,导入瞬时完成、 后台识别(显示「待处理 N」) - 照片详情:大图 + 时间/GPS + Qwen-VL 场景描述 + 人脸列表,点脸 → TagFaceDialog 标注(选已有人物或新建),自动回填同一人到其它照片 - 人物:人 → TA 的照片,可重命名;预留「已绑声纹」展示(融合声纹下一步) - PhotologViewModel:导入登记 + 标注/回填,随 Activity 存活 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
400 additions
and
0 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -126,6 +126,7 @@ private enum class Tab(val label: String, val icon: String) { |
| 126 | 126 | Timeline("时间轴", "≣"), |
| 127 | 127 | Speakers("声纹", "👤"), |
| 128 | 128 | Summaries("纪要", "✎"), |
| 129 | + Photolog("相册", "🖼"), | |
| 129 | 130 | Settings("设置", "⚙"), |
| 130 | 131 | } |
| 131 | 132 | |
| ... | ... | @@ -200,6 +201,7 @@ private fun MainScreen() { |
| 200 | 201 | tab = Tab.Timeline |
| 201 | 202 | }, |
| 202 | 203 | ) |
| 204 | + Tab.Photolog -> PhotologTab(repo) | |
| 203 | 205 | Tab.Settings -> SettingsScreen(activity) |
| 204 | 206 | } |
| 205 | 207 | } | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | |
| 2 | + | |
| 3 | +import androidx.activity.compose.rememberLauncherForActivityResult | |
| 4 | +import androidx.activity.result.PickVisualMediaRequest | |
| 5 | +import androidx.activity.result.contract.ActivityResultContracts | |
| 6 | +import androidx.compose.foundation.clickable | |
| 7 | +import androidx.compose.foundation.layout.Arrangement | |
| 8 | +import androidx.compose.foundation.layout.Box | |
| 9 | +import androidx.compose.foundation.layout.Column | |
| 10 | +import androidx.compose.foundation.layout.Row | |
| 11 | +import androidx.compose.foundation.layout.Spacer | |
| 12 | +import androidx.compose.foundation.layout.aspectRatio | |
| 13 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 14 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 15 | +import androidx.compose.foundation.layout.height | |
| 16 | +import androidx.compose.foundation.layout.padding | |
| 17 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 18 | +import androidx.compose.foundation.lazy.grid.GridCells | |
| 19 | +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | |
| 20 | +import androidx.compose.foundation.lazy.grid.items as gridItems | |
| 21 | +import androidx.compose.foundation.lazy.items | |
| 22 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 23 | +import androidx.compose.material3.AlertDialog | |
| 24 | +import androidx.compose.material3.AssistChip | |
| 25 | +import androidx.compose.material3.Button | |
| 26 | +import androidx.compose.material3.Card | |
| 27 | +import androidx.compose.material3.CircularProgressIndicator | |
| 28 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 29 | +import androidx.compose.material3.FilterChip | |
| 30 | +import androidx.compose.material3.HorizontalDivider | |
| 31 | +import androidx.compose.material3.MaterialTheme | |
| 32 | +import androidx.compose.material3.ModalBottomSheet | |
| 33 | +import androidx.compose.material3.OutlinedButton | |
| 34 | +import androidx.compose.material3.OutlinedTextField | |
| 35 | +import androidx.compose.material3.RadioButton | |
| 36 | +import androidx.compose.material3.Surface | |
| 37 | +import androidx.compose.material3.Tab | |
| 38 | +import androidx.compose.material3.TabRow | |
| 39 | +import androidx.compose.material3.Text | |
| 40 | +import androidx.compose.material3.TextButton | |
| 41 | +import androidx.compose.material3.rememberModalBottomSheetState | |
| 42 | +import androidx.compose.runtime.Composable | |
| 43 | +import androidx.compose.runtime.collectAsState | |
| 44 | +import androidx.compose.runtime.getValue | |
| 45 | +import androidx.compose.runtime.mutableStateOf | |
| 46 | +import androidx.compose.runtime.produceState | |
| 47 | +import androidx.compose.runtime.remember | |
| 48 | +import androidx.compose.runtime.setValue | |
| 49 | +import androidx.compose.ui.Alignment | |
| 50 | +import androidx.compose.ui.Modifier | |
| 51 | +import androidx.compose.ui.layout.ContentScale | |
| 52 | +import androidx.compose.ui.text.style.TextOverflow | |
| 53 | +import androidx.compose.ui.unit.dp | |
| 54 | +import androidx.lifecycle.viewmodel.compose.viewModel | |
| 55 | +import coil.compose.AsyncImage | |
| 56 | +import com.lemon.lifelog.data.Face | |
| 57 | +import com.lemon.lifelog.data.Person | |
| 58 | +import com.lemon.lifelog.data.PhotoRow | |
| 59 | +import com.lemon.lifelog.data.Repository | |
| 60 | +import kotlinx.coroutines.Dispatchers | |
| 61 | +import kotlinx.coroutines.flow.flowOf | |
| 62 | +import kotlinx.coroutines.withContext | |
| 63 | +import java.io.File | |
| 64 | +import java.text.SimpleDateFormat | |
| 65 | +import java.util.Date | |
| 66 | +import java.util.Locale | |
| 67 | + | |
| 68 | +private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) | |
| 69 | + | |
| 70 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 71 | +@Composable | |
| 72 | +internal fun PhotologTab(repo: Repository) { | |
| 73 | + val vm: PhotologViewModel = viewModel() | |
| 74 | + var view by remember { mutableStateOf(0) } // 0=照片 1=人物 | |
| 75 | + var filterPersonId by remember { mutableStateOf<Long?>(null) } | |
| 76 | + var detailPhotoId by remember { mutableStateOf<Long?>(null) } | |
| 77 | + | |
| 78 | + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) | |
| 79 | + val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) | |
| 80 | + val personName = remember(persons, filterPersonId) { persons.firstOrNull { it.id == filterPersonId }?.name } | |
| 81 | + | |
| 82 | + val photos by remember(filterPersonId) { | |
| 83 | + val pid = filterPersonId | |
| 84 | + if (pid == null) vm.repo.photos.observeGrid(1000) else vm.repo.photos.observePhotosOfPerson(pid) | |
| 85 | + }.collectAsState(initial = emptyList()) | |
| 86 | + | |
| 87 | + val picker = rememberLauncherForActivityResult( | |
| 88 | + ActivityResultContracts.PickMultipleVisualMedia(30), | |
| 89 | + ) { uris -> vm.importUris(uris) } | |
| 90 | + | |
| 91 | + Column(modifier = Modifier.fillMaxSize()) { | |
| 92 | + TabRow(selectedTabIndex = view) { | |
| 93 | + Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) | |
| 94 | + Tab(selected = view == 1, onClick = { view = 1 }, text = { Text("人物") }) | |
| 95 | + } | |
| 96 | + | |
| 97 | + if (view == 0) { | |
| 98 | + // 操作行:导入 + 人物筛选 + 处理进度。 | |
| 99 | + Row( | |
| 100 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | |
| 101 | + verticalAlignment = Alignment.CenterVertically, | |
| 102 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 103 | + ) { | |
| 104 | + Button(onClick = { | |
| 105 | + picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) | |
| 106 | + }) { Text("+ 导入照片") } | |
| 107 | + if (filterPersonId != null) { | |
| 108 | + FilterChip(selected = true, onClick = { filterPersonId = null }, label = { Text("👤 ${personName ?: "?"} ✕") }) | |
| 109 | + } | |
| 110 | + if (pendingCount > 0) { | |
| 111 | + CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) | |
| 112 | + Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + if (photos.isEmpty()) { | |
| 117 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 118 | + Text( | |
| 119 | + if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。", | |
| 120 | + style = MaterialTheme.typography.bodyMedium, | |
| 121 | + ) | |
| 122 | + } | |
| 123 | + } else { | |
| 124 | + LazyVerticalGrid( | |
| 125 | + columns = GridCells.Fixed(3), | |
| 126 | + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), | |
| 127 | + verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 128 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 129 | + ) { | |
| 130 | + gridItems(photos, key = { it.id }) { p -> | |
| 131 | + PhotoCell(p) { detailPhotoId = p.id } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } else { | |
| 136 | + PeopleList( | |
| 137 | + vm = vm, | |
| 138 | + persons = persons, | |
| 139 | + onOpen = { id -> filterPersonId = id; view = 0 }, | |
| 140 | + ) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + detailPhotoId?.let { id -> | |
| 145 | + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +@Composable | |
| 150 | +private fun PhotoCell(p: PhotoRow, onClick: () -> Unit) { | |
| 151 | + Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { | |
| 152 | + AsyncImage( | |
| 153 | + model = File(p.path), | |
| 154 | + contentDescription = null, | |
| 155 | + contentScale = ContentScale.Crop, | |
| 156 | + modifier = Modifier.fillMaxSize(), | |
| 157 | + ) | |
| 158 | + if (p.faceCount > 0) { | |
| 159 | + Surface( | |
| 160 | + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), | |
| 161 | + shape = RoundedCornerShape(8.dp), | |
| 162 | + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp), | |
| 163 | + ) { | |
| 164 | + Text( | |
| 165 | + "👤${p.faceCount}", | |
| 166 | + style = MaterialTheme.typography.labelSmall, | |
| 167 | + color = MaterialTheme.colorScheme.onPrimary, | |
| 168 | + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), | |
| 169 | + ) | |
| 170 | + } | |
| 171 | + } | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 176 | +@Composable | |
| 177 | +private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List<Person>, onDismiss: () -> Unit) { | |
| 178 | + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) | |
| 179 | + val photo by produceState<com.lemon.lifelog.data.Photo?>(initialValue = null, photoId) { | |
| 180 | + value = withContext(Dispatchers.IO) { vm.repo.photos.get(photoId) } | |
| 181 | + } | |
| 182 | + val faces by remember(photoId) { vm.repo.faces.observeByPhoto(photoId) }.collectAsState(initial = emptyList()) | |
| 183 | + val nameOf = remember(persons) { persons.associate { it.id to it.name } } | |
| 184 | + var tagFace by remember { mutableStateOf<Face?>(null) } | |
| 185 | + | |
| 186 | + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) { | |
| 187 | + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { | |
| 188 | + photo?.let { p -> | |
| 189 | + AsyncImage( | |
| 190 | + model = File(p.path), | |
| 191 | + contentDescription = null, | |
| 192 | + contentScale = ContentScale.Fit, | |
| 193 | + modifier = Modifier.fillMaxWidth().height(280.dp), | |
| 194 | + ) | |
| 195 | + Spacer(Modifier.height(8.dp)) | |
| 196 | + Text(PHOTO_FMT.format(Date(p.takenTs)) + (if (p.lat != null) " 📍${"%.4f".format(p.lat)},${"%.4f".format(p.lon)}" else ""), | |
| 197 | + style = MaterialTheme.typography.labelMedium) | |
| 198 | + p.caption?.let { Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) } | |
| 199 | + HorizontalDivider() | |
| 200 | + Text("人脸(${faces.size})— 点击标注", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(vertical = 6.dp)) | |
| 201 | + if (faces.isEmpty()) Text("这张没检测到人脸。", style = MaterialTheme.typography.bodySmall) | |
| 202 | + faces.forEachIndexed { i, f -> | |
| 203 | + val label = f.personId?.let { nameOf[it] } | |
| 204 | + Row( | |
| 205 | + modifier = Modifier.fillMaxWidth().clickable { tagFace = f }.padding(vertical = 8.dp), | |
| 206 | + horizontalArrangement = Arrangement.SpaceBetween, | |
| 207 | + verticalAlignment = Alignment.CenterVertically, | |
| 208 | + ) { | |
| 209 | + Text("脸 #${i + 1}", style = MaterialTheme.typography.bodyMedium) | |
| 210 | + AssistChip( | |
| 211 | + onClick = { tagFace = f }, | |
| 212 | + label = { Text(label ?: "未命名 → 标注") }, | |
| 213 | + ) | |
| 214 | + } | |
| 215 | + } | |
| 216 | + Spacer(Modifier.height(8.dp)) | |
| 217 | + OutlinedButton(onClick = { vm.deletePhoto(photoId); onDismiss() }, modifier = Modifier.fillMaxWidth()) { | |
| 218 | + Text("删除这张照片") | |
| 219 | + } | |
| 220 | + } ?: Text("加载中…") | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + tagFace?.let { f -> | |
| 225 | + TagFaceDialog( | |
| 226 | + persons = persons, | |
| 227 | + current = f.personId, | |
| 228 | + onDismiss = { tagFace = null }, | |
| 229 | + onPick = { pid -> vm.tagFace(f.id, pid); tagFace = null }, | |
| 230 | + onCreate = { name -> vm.createPersonAndTag(f.id, name); tagFace = null }, | |
| 231 | + onClear = { vm.clearFace(f.id); tagFace = null }, | |
| 232 | + ) | |
| 233 | + } | |
| 234 | +} | |
| 235 | + | |
| 236 | +@Composable | |
| 237 | +private fun TagFaceDialog( | |
| 238 | + persons: List<Person>, | |
| 239 | + current: Long?, | |
| 240 | + onDismiss: () -> Unit, | |
| 241 | + onPick: (Long) -> Unit, | |
| 242 | + onCreate: (String) -> Unit, | |
| 243 | + onClear: () -> Unit, | |
| 244 | +) { | |
| 245 | + var newName by remember { mutableStateOf("") } | |
| 246 | + AlertDialog( | |
| 247 | + onDismissRequest = onDismiss, | |
| 248 | + title = { Text("这张脸是谁?") }, | |
| 249 | + text = { | |
| 250 | + Column { | |
| 251 | + Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.bodySmall) | |
| 252 | + Spacer(Modifier.height(8.dp)) | |
| 253 | + persons.forEach { p -> | |
| 254 | + Row( | |
| 255 | + modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), | |
| 256 | + verticalAlignment = Alignment.CenterVertically, | |
| 257 | + ) { | |
| 258 | + RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) | |
| 259 | + Text(p.name) | |
| 260 | + } | |
| 261 | + } | |
| 262 | + Spacer(Modifier.height(4.dp)) | |
| 263 | + OutlinedTextField( | |
| 264 | + value = newName, onValueChange = { newName = it }, | |
| 265 | + label = { Text("或新建一个人(名字)") }, singleLine = true, | |
| 266 | + modifier = Modifier.fillMaxWidth(), | |
| 267 | + ) | |
| 268 | + if (current != null) { | |
| 269 | + TextButton(onClick = onClear) { Text("清除归属") } | |
| 270 | + } | |
| 271 | + } | |
| 272 | + }, | |
| 273 | + confirmButton = { | |
| 274 | + TextButton(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } | |
| 275 | + }, | |
| 276 | + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | |
| 277 | + ) | |
| 278 | +} | |
| 279 | + | |
| 280 | +@Composable | |
| 281 | +private fun PeopleList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit) { | |
| 282 | + var renameFor by remember { mutableStateOf<Person?>(null) } | |
| 283 | + if (persons.isEmpty()) { | |
| 284 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 285 | + Text("还没有人物。去「照片」里点一张脸标注,就会自动建立人物并归类照片。", style = MaterialTheme.typography.bodyMedium) | |
| 286 | + } | |
| 287 | + return | |
| 288 | + } | |
| 289 | + LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 290 | + items(persons, key = { it.id }) { p -> | |
| 291 | + val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } } | |
| 292 | + Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) { | |
| 293 | + Row( | |
| 294 | + modifier = Modifier.fillMaxWidth().padding(14.dp), | |
| 295 | + horizontalArrangement = Arrangement.SpaceBetween, | |
| 296 | + verticalAlignment = Alignment.CenterVertically, | |
| 297 | + ) { | |
| 298 | + Column { | |
| 299 | + Text(p.name, style = MaterialTheme.typography.titleSmall) | |
| 300 | + Text("$count 张人脸" + (if (p.voiceSpeakerId != null) " · 已绑声纹" else ""), style = MaterialTheme.typography.labelSmall) | |
| 301 | + } | |
| 302 | + TextButton(onClick = { renameFor = p }) { Text("重命名") } | |
| 303 | + } | |
| 304 | + } | |
| 305 | + } | |
| 306 | + } | |
| 307 | + renameFor?.let { p -> | |
| 308 | + var name by remember { mutableStateOf(p.name) } | |
| 309 | + AlertDialog( | |
| 310 | + onDismissRequest = { renameFor = null }, | |
| 311 | + title = { Text("重命名") }, | |
| 312 | + text = { OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true) }, | |
| 313 | + confirmButton = { TextButton(onClick = { vm.renamePerson(p.id, name); renameFor = null }, enabled = name.isNotBlank()) { Text("保存") } }, | |
| 314 | + dismissButton = { TextButton(onClick = { renameFor = null }) { Text("取消") } }, | |
| 315 | + ) | |
| 316 | + } | |
| 317 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
0 → 100644
| 1 | +package com.lemon.lifelog | |
| 2 | + | |
| 3 | +import android.app.Application | |
| 4 | +import android.net.Uri | |
| 5 | +import android.util.Log | |
| 6 | +import androidx.lifecycle.AndroidViewModel | |
| 7 | +import androidx.lifecycle.viewModelScope | |
| 8 | +import com.lemon.lifelog.data.Person | |
| 9 | +import com.lemon.lifelog.data.Photo | |
| 10 | +import com.lemon.lifelog.data.Repository | |
| 11 | +import com.lemon.lifelog.vision.FaceMatcher | |
| 12 | +import com.lemon.lifelog.vision.ImageUtil | |
| 13 | +import com.lemon.lifelog.vision.PhotoScheduler | |
| 14 | +import kotlinx.coroutines.Dispatchers | |
| 15 | +import kotlinx.coroutines.launch | |
| 16 | +import kotlinx.coroutines.withContext | |
| 17 | +import java.io.File | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 相册:导入照片(拷进 app 私有目录,保隐私) = **瞬时登记**,重活(人脸+VL)交给 | |
| 21 | + * 后台 PhotoWorker 闲时/定时处理,不阻塞导入。标注 + 回填(快,留在 VM)。 | |
| 22 | + */ | |
| 23 | +class PhotologViewModel(app: Application) : AndroidViewModel(app) { | |
| 24 | + val repo = Repository(app) | |
| 25 | + | |
| 26 | + /** 导入:只拷文件 + 登记 photos 行(processedAt=null),瞬时返回;触发后台 worker。 */ | |
| 27 | + fun importUris(uris: List<Uri>) { | |
| 28 | + if (uris.isEmpty()) return | |
| 29 | + val app = getApplication<Application>() | |
| 30 | + viewModelScope.launch { | |
| 31 | + val dir = File(app.filesDir, "photos").apply { mkdirs() } | |
| 32 | + withContext(Dispatchers.IO) { | |
| 33 | + for (uri in uris) { | |
| 34 | + try { | |
| 35 | + val dest = File(dir, "img_${System.currentTimeMillis()}_${uri.hashCode()}.jpg") | |
| 36 | + app.contentResolver.openInputStream(uri)?.use { ins -> | |
| 37 | + dest.outputStream().use { ins.copyTo(it) } | |
| 38 | + } | |
| 39 | + if (repo.photos.getByPath(dest.absolutePath) == null) { | |
| 40 | + val meta = ImageUtil.readMeta(dest.absolutePath) | |
| 41 | + repo.photos.insert( | |
| 42 | + Photo(path = dest.absolutePath, takenTs = meta.takenTs, lat = meta.lat, lon = meta.lon), | |
| 43 | + ) | |
| 44 | + } | |
| 45 | + } catch (t: Throwable) { | |
| 46 | + Log.w("PhotologVM", "import failed: ${t.message}") | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + PhotoScheduler.runNow(app) // 让后台尽快开工 | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + fun createPersonAndTag(faceId: Long, name: String) { | |
| 55 | + viewModelScope.launch { | |
| 56 | + val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim())) } | |
| 57 | + FaceMatcher.assignAndBackfill(repo, faceId, pid) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + fun tagFace(faceId: Long, personId: Long) { | |
| 62 | + viewModelScope.launch { FaceMatcher.assignAndBackfill(repo, faceId, personId) } | |
| 63 | + } | |
| 64 | + | |
| 65 | + fun clearFace(faceId: Long) { | |
| 66 | + viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } | |
| 67 | + } | |
| 68 | + | |
| 69 | + fun renamePerson(personId: Long, name: String) { | |
| 70 | + viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } | |
| 71 | + } | |
| 72 | + | |
| 73 | + fun deletePhoto(photoId: Long) { | |
| 74 | + viewModelScope.launch { | |
| 75 | + withContext(Dispatchers.IO) { | |
| 76 | + repo.faces.deleteByPhoto(photoId) | |
| 77 | + repo.photos.delete(photoId) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | ... | ... |