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,6 +126,7 @@ private enum class Tab(val label: String, val icon: String) { | ||
| 126 | Timeline("时间轴", "≣"), | 126 | Timeline("时间轴", "≣"), |
| 127 | Speakers("声纹", "👤"), | 127 | Speakers("声纹", "👤"), |
| 128 | Summaries("纪要", "✎"), | 128 | Summaries("纪要", "✎"), |
| 129 | + Photolog("相册", "🖼"), | ||
| 129 | Settings("设置", "⚙"), | 130 | Settings("设置", "⚙"), |
| 130 | } | 131 | } |
| 131 | 132 | ||
| @@ -200,6 +201,7 @@ private fun MainScreen() { | @@ -200,6 +201,7 @@ private fun MainScreen() { | ||
| 200 | tab = Tab.Timeline | 201 | tab = Tab.Timeline |
| 201 | }, | 202 | }, |
| 202 | ) | 203 | ) |
| 204 | + Tab.Photolog -> PhotologTab(repo) | ||
| 203 | Tab.Settings -> SettingsScreen(activity) | 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 | +} |