From 3bf1909fe0eb0607471edbba0507c53a8ada8af1 Mon Sep 17 00:00:00 2001 From: yibo Date: Fri, 5 Jun 2026 16:36:26 +0800 Subject: [PATCH] feat(lifelog/photolog): v0 UI — 相册 tab + 照片网格 + 人脸标注 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 2 ++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 0 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index e148cf9..c66703b 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/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) { Timeline("时间轴", "≣"), Speakers("声纹", "👤"), Summaries("纪要", "✎"), + Photolog("相册", "🖼"), Settings("设置", "⚙"), } @@ -200,6 +201,7 @@ private fun MainScreen() { tab = Tab.Timeline }, ) + Tab.Photolog -> PhotologTab(repo) Tab.Settings -> SettingsScreen(activity) } } 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 new file mode 100644 index 0000000..d188bef --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -0,0 +1,317 @@ +package com.lemon.lifelog + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.lemon.lifelog.data.Face +import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.PhotoRow +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PhotologTab(repo: Repository) { + val vm: PhotologViewModel = viewModel() + var view by remember { mutableStateOf(0) } // 0=照片 1=人物 + var filterPersonId by remember { mutableStateOf(null) } + var detailPhotoId by remember { mutableStateOf(null) } + + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) + val personName = remember(persons, filterPersonId) { persons.firstOrNull { it.id == filterPersonId }?.name } + + val photos by remember(filterPersonId) { + val pid = filterPersonId + if (pid == null) vm.repo.photos.observeGrid(1000) else vm.repo.photos.observePhotosOfPerson(pid) + }.collectAsState(initial = emptyList()) + + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(30), + ) { uris -> vm.importUris(uris) } + + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = view) { + Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) + Tab(selected = view == 1, onClick = { view = 1 }, text = { Text("人物") }) + } + + if (view == 0) { + // 操作行:导入 + 人物筛选 + 处理进度。 + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { + picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }) { Text("+ 导入照片") } + if (filterPersonId != null) { + FilterChip(selected = true, onClick = { filterPersonId = null }, label = { Text("👤 ${personName ?: "?"} ✕") }) + } + if (pendingCount > 0) { + CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) + Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall) + } + } + + if (photos.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + gridItems(photos, key = { it.id }) { p -> + PhotoCell(p) { detailPhotoId = p.id } + } + } + } + } else { + PeopleList( + vm = vm, + persons = persons, + onOpen = { id -> filterPersonId = id; view = 0 }, + ) + } + } + + detailPhotoId?.let { id -> + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) + } +} + +@Composable +private fun PhotoCell(p: PhotoRow, onClick: () -> Unit) { + Box(modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick)) { + AsyncImage( + model = File(p.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + if (p.faceCount > 0) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp), + ) { + Text( + "👤${p.faceCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List, onDismiss: () -> Unit) { + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val photo by produceState(initialValue = null, photoId) { + value = withContext(Dispatchers.IO) { vm.repo.photos.get(photoId) } + } + val faces by remember(photoId) { vm.repo.faces.observeByPhoto(photoId) }.collectAsState(initial = emptyList()) + val nameOf = remember(persons) { persons.associate { it.id to it.name } } + var tagFace by remember { mutableStateOf(null) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + photo?.let { p -> + AsyncImage( + model = File(p.path), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth().height(280.dp), + ) + Spacer(Modifier.height(8.dp)) + Text(PHOTO_FMT.format(Date(p.takenTs)) + (if (p.lat != null) " 📍${"%.4f".format(p.lat)},${"%.4f".format(p.lon)}" else ""), + style = MaterialTheme.typography.labelMedium) + p.caption?.let { Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) } + HorizontalDivider() + Text("人脸(${faces.size})— 点击标注", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(vertical = 6.dp)) + if (faces.isEmpty()) Text("这张没检测到人脸。", style = MaterialTheme.typography.bodySmall) + faces.forEachIndexed { i, f -> + val label = f.personId?.let { nameOf[it] } + Row( + modifier = Modifier.fillMaxWidth().clickable { tagFace = f }.padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("脸 #${i + 1}", style = MaterialTheme.typography.bodyMedium) + AssistChip( + onClick = { tagFace = f }, + label = { Text(label ?: "未命名 → 标注") }, + ) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { vm.deletePhoto(photoId); onDismiss() }, modifier = Modifier.fillMaxWidth()) { + Text("删除这张照片") + } + } ?: Text("加载中…") + } + } + + tagFace?.let { f -> + TagFaceDialog( + persons = persons, + current = f.personId, + onDismiss = { tagFace = null }, + onPick = { pid -> vm.tagFace(f.id, pid); tagFace = null }, + onCreate = { name -> vm.createPersonAndTag(f.id, name); tagFace = null }, + onClear = { vm.clearFace(f.id); tagFace = null }, + ) + } +} + +@Composable +private fun TagFaceDialog( + persons: List, + current: Long?, + onDismiss: () -> Unit, + onPick: (Long) -> Unit, + onCreate: (String) -> Unit, + onClear: () -> Unit, +) { + var newName by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("这张脸是谁?") }, + text = { + Column { + Text("标注后会自动在其它照片里找同一个人。", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + persons.forEach { p -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(p.id) }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = current == p.id, onClick = { onPick(p.id) }) + Text(p.name) + } + } + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = newName, onValueChange = { newName = it }, + label = { Text("或新建一个人(名字)") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (current != null) { + TextButton(onClick = onClear) { Text("清除归属") } + } + } + }, + confirmButton = { + TextButton(onClick = { onCreate(newName) }, enabled = newName.isNotBlank()) { Text("新建并标注") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun PeopleList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit) { + var renameFor by remember { mutableStateOf(null) } + if (persons.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("还没有人物。去「照片」里点一张脸标注,就会自动建立人物并归类照片。", style = MaterialTheme.typography.bodyMedium) + } + return + } + LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(persons, key = { it.id }) { p -> + val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } } + Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(p.name, style = MaterialTheme.typography.titleSmall) + Text("$count 张人脸" + (if (p.voiceSpeakerId != null) " · 已绑声纹" else ""), style = MaterialTheme.typography.labelSmall) + } + TextButton(onClick = { renameFor = p }) { Text("重命名") } + } + } + } + } + renameFor?.let { p -> + var name by remember { mutableStateOf(p.name) } + AlertDialog( + onDismissRequest = { renameFor = null }, + title = { Text("重命名") }, + text = { OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true) }, + confirmButton = { TextButton(onClick = { vm.renamePerson(p.id, name); renameFor = null }, enabled = name.isNotBlank()) { Text("保存") } }, + dismissButton = { TextButton(onClick = { renameFor = null }) { Text("取消") } }, + ) + } +} 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 new file mode 100644 index 0000000..5d057f4 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -0,0 +1,81 @@ +package com.lemon.lifelog + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.Photo +import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.vision.FaceMatcher +import com.lemon.lifelog.vision.ImageUtil +import com.lemon.lifelog.vision.PhotoScheduler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * 相册:导入照片(拷进 app 私有目录,保隐私) = **瞬时登记**,重活(人脸+VL)交给 + * 后台 PhotoWorker 闲时/定时处理,不阻塞导入。标注 + 回填(快,留在 VM)。 + */ +class PhotologViewModel(app: Application) : AndroidViewModel(app) { + val repo = Repository(app) + + /** 导入:只拷文件 + 登记 photos 行(processedAt=null),瞬时返回;触发后台 worker。 */ + fun importUris(uris: List) { + if (uris.isEmpty()) return + val app = getApplication() + viewModelScope.launch { + val dir = File(app.filesDir, "photos").apply { mkdirs() } + withContext(Dispatchers.IO) { + for (uri in uris) { + try { + val dest = File(dir, "img_${System.currentTimeMillis()}_${uri.hashCode()}.jpg") + app.contentResolver.openInputStream(uri)?.use { ins -> + dest.outputStream().use { ins.copyTo(it) } + } + if (repo.photos.getByPath(dest.absolutePath) == null) { + val meta = ImageUtil.readMeta(dest.absolutePath) + repo.photos.insert( + Photo(path = dest.absolutePath, takenTs = meta.takenTs, lat = meta.lat, lon = meta.lon), + ) + } + } catch (t: Throwable) { + Log.w("PhotologVM", "import failed: ${t.message}") + } + } + } + PhotoScheduler.runNow(app) // 让后台尽快开工 + } + } + + fun createPersonAndTag(faceId: Long, name: String) { + viewModelScope.launch { + val pid = withContext(Dispatchers.IO) { repo.persons.insert(Person(name = name.trim())) } + FaceMatcher.assignAndBackfill(repo, faceId, pid) + } + } + + fun tagFace(faceId: Long, personId: Long) { + viewModelScope.launch { FaceMatcher.assignAndBackfill(repo, faceId, personId) } + } + + fun clearFace(faceId: Long) { + viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } + } + + fun renamePerson(personId: Long, name: String) { + viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } + } + + fun deletePhoto(photoId: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.faces.deleteByPhoto(photoId) + repo.photos.delete(photoId) + } + } + } +} -- libgit2 0.21.4