Commit a231863d1cb36298eafd8dd74db6168f867022b8

Authored by yibo
1 parent d2f15d2c

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>
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 +}