diff --git a/app/src/main/java/me/grey/picquery/common/AppModules.kt b/app/src/main/java/me/grey/picquery/common/AppModules.kt index a0367a9..dbfac2c 100644 --- a/app/src/main/java/me/grey/picquery/common/AppModules.kt +++ b/app/src/main/java/me/grey/picquery/common/AppModules.kt @@ -64,6 +64,7 @@ private val domainModules = module { AlbumManager( albumRepository = get(), photoRepository = get(), + embeddingRepository = get(), imageSearcher = get(), ioDispatcher = get() ) diff --git a/app/src/main/java/me/grey/picquery/common/Routes.kt b/app/src/main/java/me/grey/picquery/common/Routes.kt index def030d..ff48606 100644 --- a/app/src/main/java/me/grey/picquery/common/Routes.kt +++ b/app/src/main/java/me/grey/picquery/common/Routes.kt @@ -4,5 +4,6 @@ enum class Routes { Home, Search, Display, + IndexMgr, Setting } diff --git a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt index 8973386..9987133 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt @@ -36,8 +36,11 @@ interface EmbeddingDao { ) fun getByAlbumIdList(albumIds: List, limit: Int, offset: Int): List -// @Insert -// fun insertAll(embeddings: List) + @Query( + "DELETE FROM $tableName WHERE album_id=(:albumId)" + ) + fun removeByAlbumId(albumId: Long): Unit + @Upsert fun upsertAll(embeddings: List) diff --git a/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt index 7aee96f..335c75f 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt @@ -92,4 +92,8 @@ class AlbumRepository( fun addAllSearchableAlbum(album: List) { return database.albumDao().upsertAll(album) } + + fun removeSearchableAlbum(singleAlbum: Album) { + database.albumDao().delete(singleAlbum) + } } \ No newline at end of file diff --git a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt index dee72fb..0effa5c 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt @@ -81,4 +81,8 @@ class EmbeddingRepository( fun updateAll(list: List) { return dataSource.upsertAll(list) } + + fun removeByAlbum(album: Album){ + return dataSource.removeByAlbumId(album.id) + } } \ No newline at end of file diff --git a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt index ad161d8..139afca 100644 --- a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt @@ -8,22 +8,24 @@ import androidx.compose.runtime.mutableStateOf import com.google.accompanist.permissions.ExperimentalPermissionsApi import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.grey.picquery.PicQueryApplication import me.grey.picquery.PicQueryApplication.Companion.context import me.grey.picquery.R import me.grey.picquery.common.showToast import me.grey.picquery.data.data_source.AlbumRepository +import me.grey.picquery.data.data_source.EmbeddingRepository import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.model.Album import me.grey.picquery.ui.albums.IndexingAlbumState +import java.util.concurrent.atomic.AtomicInteger class AlbumManager( private val albumRepository: AlbumRepository, private val photoRepository: PhotoRepository, + private val embeddingRepository: EmbeddingRepository, private val imageSearcher: ImageSearcher, private val ioDispatcher: CoroutineDispatcher ) { @@ -37,14 +39,16 @@ class AlbumManager( get() = indexingAlbumState.value.isBusy private val albumList = mutableStateListOf() - val searchableAlbumList = mutableStateListOf() - val unsearchableAlbumList = mutableStateListOf() + val searchableAlbumList = MutableStateFlow>(emptyList()) + val unsearchableAlbumList = MutableStateFlow>(emptyList()) val albumsToEncode = mutableStateListOf() private fun searchableAlbumFlow() = albumRepository.getSearchableAlbumFlow() private var initialized = false + fun getAlbumList() = albumList + suspend fun initAllAlbumList() { if (initialized) return withContext(ioDispatcher) { @@ -57,18 +61,18 @@ class AlbumManager( } } - private suspend fun initDataFlow() { + suspend fun initDataFlow() { searchableAlbumFlow().collect { // 从数据库中检索已经索引的相册 // 有些相册可能已经索引但已被删除,因此要从全部相册中筛选,而不能直接返回数据库的结果 - searchableAlbumList.clear() - searchableAlbumList.addAll(it) - searchableAlbumList.sortByDescending { album: Album -> album.count } + val res = it.toMutableList().sortedByDescending { album: Album -> album.count } + searchableAlbumList.emit(res) + Log.d(TAG, "Searchable albums: ${it.size}") // 从全部相册减去已经索引的ID,就是未索引的相册 val unsearchable = albumList.filter { all -> !it.contains(all) } - unsearchableAlbumList.clear() - unsearchableAlbumList.addAll(unsearchable) - unsearchableAlbumList.sortByDescending { album: Album -> album.count } + + unsearchableAlbumList.emit(unsearchable.toMutableList().sortedByDescending { album: Album -> album.count }) + Log.d(TAG, "Unsearchable albums: ${unsearchable.size}") } } @@ -81,9 +85,9 @@ class AlbumManager( } fun toggleSelectAllAlbums() { - if (albumsToEncode.size != unsearchableAlbumList.size) { + if (albumsToEncode.size != unsearchableAlbumList.value?.size) { albumsToEncode.clear() - albumsToEncode.addAll(unsearchableAlbumList) + albumsToEncode.addAll(unsearchableAlbumList.value) } else { albumsToEncode.clear() } @@ -92,11 +96,11 @@ class AlbumManager( /** * 获取多个相册的照片流 */ -@OptIn(ExperimentalCoroutinesApi::class) -private fun getPhotosFlow(albums: List) = albums.asFlow() - .flatMapConcat { album -> - photoRepository.getPhotoListByAlbumIdPaginated(album.id) - } + @OptIn(ExperimentalCoroutinesApi::class) + private fun getPhotosFlow(albums: List) = albums.asFlow() + .flatMapConcat { album -> + photoRepository.getPhotoListByAlbumIdPaginated(album.id) + } /** * 获取相册列表的照片总数 @@ -105,62 +109,64 @@ private fun getPhotosFlow(albums: List) = albums.asFlow() albums.sumOf { album -> photoRepository.getImageCountInAlbum(album.id) } } - fun encodeAlbums(albums: List) { - PicQueryApplication.applicationScope.launch { - if (albums.isEmpty()) { - showToast(context.getString(R.string.no_album_selected)) - return@launch - } + suspend fun encodeAlbums(albums: List) { + if (albums.isEmpty()) { + showToast(context.getString(R.string.no_album_selected)) + return + } - indexingAlbumState.value = - IndexingAlbumState(status = IndexingAlbumState.Status.Loading) - - try { - // 先获取总照片数 - val totalPhotos = getTotalPhotoCount(albums) - var processedPhotos = 0 - var success = true - - // 分批处理照片 - getPhotosFlow(albums).collect { photoChunk -> - // 对每一批照片进行编码 - val chunkSuccess = imageSearcher.encodePhotoListV2(photoChunk) { cur, total, cost -> - processedPhotos += cur - indexingAlbumState.value = indexingAlbumState.value.copy( - current = processedPhotos, - total = totalPhotos, - cost = cost, - status = IndexingAlbumState.Status.Indexing - ) - } - - if (!chunkSuccess) { - success = false - Log.w(TAG, "Failed to encode photo chunk, size: ${photoChunk.size}") - } - } + indexingAlbumState.value = + IndexingAlbumState(status = IndexingAlbumState.Status.Loading) + + try { + val totalPhotos = getTotalPhotoCount(albums) + val processedPhotos = AtomicInteger(0) + var success = true - if (success) { - Log.i(TAG, "Encoded ${albums.size} album(s) with $totalPhotos photos!") - withContext(ioDispatcher) { - albumRepository.addAllSearchableAlbum(albums) - } + getPhotosFlow(albums).collect { photoChunk -> + + val chunkSuccess = imageSearcher.encodePhotoListV2(photoChunk) { cur, total, cost -> + Log.d(TAG, "Encoded $cur/$total photos, cost: $cost") + processedPhotos.addAndGet(cur) indexingAlbumState.value = indexingAlbumState.value.copy( - status = IndexingAlbumState.Status.Finish + current = processedPhotos.get(), + total = totalPhotos, + cost = cost, + status = IndexingAlbumState.Status.Indexing ) - } else { - Log.w(TAG, "encodePhotoList failed! Maybe too much request.") } - } catch (e: Exception) { - Log.e(TAG, "Error encoding albums", e) + + if (!chunkSuccess) { + success = false + Log.w(TAG, "Failed to encode photo chunk, size: ${photoChunk.size}") + } + } + + if (success) { + Log.i(TAG, "Encoded ${albums.size} album(s) with $totalPhotos photos!") + withContext(ioDispatcher) { + albumRepository.addAllSearchableAlbum(albums) + } indexingAlbumState.value = indexingAlbumState.value.copy( - status = IndexingAlbumState.Status.Error + status = IndexingAlbumState.Status.Finish ) + } else { + Log.w(TAG, "encodePhotoList failed! Maybe too much request.") } + } catch (e: Exception) { + Log.e(TAG, "Error encoding albums", e) + indexingAlbumState.value = indexingAlbumState.value.copy( + status = IndexingAlbumState.Status.Error + ) } } fun clearIndexingState() { indexingAlbumState.value = IndexingAlbumState() } + + fun removeSingleAlbumIndex(album: Album) { + embeddingRepository.removeByAlbum(album) + albumRepository.removeSearchableAlbum(album) + } } \ No newline at end of file diff --git a/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt b/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt index f421233..64e0da9 100644 --- a/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt +++ b/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.vector.ImageVector +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,6 +68,8 @@ class ImageSearcher( private const val DEFAULT_MATCH_THRESHOLD = 0.01 private const val TOP_K = 30 private const val SEARCH_BATCH_SIZE = 1000 + + } val searchRange = mutableStateListOf() @@ -136,16 +140,8 @@ class ImageSearcher( .chunked(100) .onEach { Log.d(TAG, "onEach: ${it.size}") } .onCompletion { - val cost = max((System.currentTimeMillis() - startTime)/1000f, 1f) - Log.d( - TAG, - "[OK] encode ${photos.size} pics in $cost s, " + - "avg speed: ${photos.size / cost} pic/s" - ) - progressCallback?.invoke(photos.size, photos.size, 0) embeddingRepository.updateCache() encodingLock = false - } .collect { val loops = 1 @@ -165,13 +161,14 @@ class ImageSearcher( } deferreds.awaitAll() } + cur.set(it.size) + progressCallback?.invoke( cur.get(), photos.size, cost / it.size, ) Log.d(TAG, "cost: ${cost}") - cur.addAndGet(it.size) } } return true @@ -221,7 +218,7 @@ class ImageSearcher( image: Bitmap, range: List = searchRange, onSuccess: suspend (List?) -> Unit, - ) { + ) { return withContext(dispatcher) { if (searchingLock) { return@withContext @@ -255,7 +252,7 @@ class ImageSearcher( embeddings.collect { chunk -> Log.d(TAG, "Processing chunk: ${chunk.size}") totalProcessed += chunk.size - + for (emb in chunk) { val sim = calculateSimilarity(emb.data.toFloatArray(), textFeat) Log.d(TAG, "similarity: ${emb.photoId} -> $sim") diff --git a/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt b/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt index 5aa860e..e132d3e 100644 --- a/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt +++ b/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt @@ -14,6 +14,7 @@ import me.grey.picquery.common.Animation.popInAnimation import me.grey.picquery.common.Routes import me.grey.picquery.ui.display.DisplayScreen import me.grey.picquery.ui.home.HomeScreen +import me.grey.picquery.ui.indexmgr.IndexMgrScreen import me.grey.picquery.ui.search.SearchScreen import me.grey.picquery.ui.setting.SettingScreen @@ -64,6 +65,11 @@ fun AppNavHost( }, ) } + composable(Routes.IndexMgr.name) { + IndexMgrScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } composable( Routes.Setting.name, enterTransition = { popInAnimation }, @@ -72,6 +78,9 @@ fun AppNavHost( ) { SettingScreen( onNavigateBack = { navController.popBackStack() }, + navigateToIndexMgr = { + navController.navigate(Routes.IndexMgr.name) + } ) } } diff --git a/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt b/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt index 5af7c36..0fee977 100644 --- a/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt @@ -66,6 +66,7 @@ fun DisplayScreen( ) LaunchedEffect(initialPage) { + if (initialPage == -1) return@LaunchedEffect displayViewModel.loadPhotos() if (photoList.isNotEmpty()) { pagerState.scrollToPage(initialPage) diff --git a/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt index 2d84b0f..8ac76fd 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt @@ -21,11 +21,13 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import me.grey.picquery.R import me.grey.picquery.common.showToast @@ -53,7 +55,7 @@ fun AddAlbumBottomSheet( onDismissRequest = { closeSheet() }, sheetState = sheetState.sheetState, ) { - val list = remember { albumManager.unsearchableAlbumList } + val list = albumManager.unsearchableAlbumList.value.toMutableStateList() if (list.isEmpty()) { EmptyAlbumTips( onClose = { closeSheet() }, @@ -69,8 +71,12 @@ fun AddAlbumBottomSheet( if (snapshot.isEmpty()) { showToast(noAlbumTips) } else { - scope.launch { albumManager.encodeAlbums(snapshot) } + GlobalScope.launch { + albumManager.encodeAlbums(snapshot) + albumManager.initDataFlow() + } onStartIndexing() + } closeSheet() }, diff --git a/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt b/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt new file mode 100644 index 0000000..f0bde31 --- /dev/null +++ b/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt @@ -0,0 +1,255 @@ +package me.grey.picquery.ui.indexmgr + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NoPhotography +import androidx.compose.material.icons.filled.PhotoAlbum +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.grey.picquery.R +import me.grey.picquery.data.model.Album +import me.grey.picquery.domain.AlbumManager +import me.grey.picquery.ui.common.BackButton +import org.koin.compose.koinInject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +const val FlagAlbumStatusNormal = 0 +const val FlagAlbumStatusInvalid = 1 +const val FlagAlbumStatusUpdateNeeded = 2 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IndexMgrScreen( + onNavigateBack: () -> Unit, + albumManager: AlbumManager = koinInject(), +) { + + val indexedAlbum = albumManager.searchableAlbumList.collectAsState().value.toMutableStateList() + val allAlbum = albumManager.getAlbumList() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.index_mgr_title)) }, + navigationIcon = { BackButton { onNavigateBack() } }, + ) + }, + modifier = Modifier.padding(horizontal = 5.dp) + ) { + LazyColumn(modifier = Modifier.padding(it)) { + repeat(indexedAlbum.size) { index -> + val album = indexedAlbum[index] + if (allAlbum.any { a -> a.id == album.id }) { + val albumNow = allAlbum.find{ item -> item.id == album.id } + if (albumNow!!.count != album.count || albumNow.timestamp != album.timestamp) { + item { AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusUpdateNeeded) } + } else { + item { AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusNormal) } + } + } else { + item{ AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusInvalid) } + } + + } + } + } +} + +@Composable +private fun AlbumItem( + indexedAlbum: SnapshotStateList, + album: Album, + albumManager: AlbumManager, + albumStatusEnum: Int +) { + var isLoading by remember { mutableStateOf(false) } + var isDone by remember { mutableStateOf(false) } + var showConfirmDialog by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + AlbumIndexDeletionDialog( + showDialog = showConfirmDialog, + onDismiss = { showConfirmDialog = false }, + onConfirm = { + showConfirmDialog = false + if (indexedAlbum.any { it.id == album.id }) { + isLoading = true + scope.launch { + removeIndexByAlbum(album, albumManager) + }.invokeOnCompletion { + isDone = true + } + } + } + ) + + ListItem( + colors = AlbumItemColors(albumStatusEnum), + leadingContent = { + AlbumItemLeadingIcon(albumStatusEnum) + }, + headlineContent = { + AlbumItemHeadline(album.label) + }, + supportingContent = { + AlbumItemSupportingContent( + albumStatusEnum = albumStatusEnum, + album = album, + isLoading = isLoading, + isDone = isDone + ) + }, + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(MaterialTheme.shapes.medium) + .clickable( + enabled = !isLoading && !isDone, + onClick = { showConfirmDialog = true } + ) + ) +} + +@Composable +private fun AlbumIndexDeletionDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.album_confirm_delete_title)) }, + text = { Text(stringResource(R.string.album_confirm_delete_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.album_confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.album_cancel_button)) + } + } + ) + } +} + +@Composable +private fun AlbumItemLeadingIcon(albumStatusEnum: Int) { + val (icon, iconDescription) = when (albumStatusEnum) { + FlagAlbumStatusInvalid -> Icons.Filled.NoPhotography to R.string.album_invalid_icon_desc + FlagAlbumStatusUpdateNeeded -> Icons.Filled.SyncProblem to R.string.album_update_needed_icon_desc + else -> Icons.Filled.PhotoAlbum to R.string.album_normal_icon_desc + } + + Icon( + imageVector = icon, + contentDescription = stringResource(iconDescription), + tint = when (albumStatusEnum) { + FlagAlbumStatusInvalid -> MaterialTheme.colorScheme.error + FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) +} + +@Composable +private fun AlbumItemHeadline(label: String) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium + ) +} + +@Composable +private fun AlbumItemSupportingContent( + albumStatusEnum: Int, + album: Album, + isLoading: Boolean, + isDone: Boolean +) { + val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val dateStr = dateFmt.format(Date(album.timestamp * 1000)) + + val descriptionText = buildAnnotatedString { + append("${stringResource(R.string.album_photo_count)}: ${album.count}\n") + append("${stringResource(R.string.album_date)}: $dateStr\n") + + when (albumStatusEnum) { + FlagAlbumStatusInvalid -> append(stringResource(R.string.album_invalid_desc)) + FlagAlbumStatusUpdateNeeded -> append(stringResource(R.string.album_update_needed_desc)) + } + } + + when { + isDone -> Text( + text = stringResource(R.string.album_index_deleted), + color = MaterialTheme.colorScheme.secondary + ) + isLoading -> Text( + text = stringResource(R.string.album_loading), + color = MaterialTheme.colorScheme.tertiary + ) + else -> Text( + text = descriptionText, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun AlbumItemColors(albumStatusEnum: Int) = ListItemDefaults.colors( + containerColor = when (albumStatusEnum) { + FlagAlbumStatusInvalid -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) + FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.2f) + }, + headlineColor = when (albumStatusEnum) { + FlagAlbumStatusInvalid -> MaterialTheme.colorScheme.error + FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSecondaryContainer + }, + supportingColor = when (albumStatusEnum) { + FlagAlbumStatusInvalid -> MaterialTheme.colorScheme.onErrorContainer + FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + } +) + +private suspend fun removeIndexByAlbum ( album: Album, albumManager: AlbumManager){ + withContext(Dispatchers.IO) { + albumManager.removeSingleAlbumIndex(album) + albumManager.initDataFlow() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt index 49afa1b..98ea745 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -45,7 +46,7 @@ fun SearchFilterBottomSheet( albumManager: AlbumManager = koinInject(), ) { val scope = rememberCoroutineScope() - val candidates = remember { albumManager.searchableAlbumList } + val candidates = albumManager.searchableAlbumList.value.toMutableStateList() val selectedList = remember { mutableStateListOf() } selectedList.addAll(imageSearcher.searchRange.toList()) val searchAll = remember { mutableStateOf(imageSearcher.isSearchAll.value) } diff --git a/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt b/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt index 4661386..226acdc 100644 --- a/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Dataset import androidx.compose.material.icons.filled.PermDeviceInformation import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -43,11 +44,12 @@ import org.koin.androidx.compose.koinViewModel @Composable fun SettingScreen( onNavigateBack: () -> Unit, + navigateToIndexMgr: () -> Unit, ) { Scaffold( topBar = { TopAppBar( - title = { Text("设置") }, + title = { Text(stringResource(R.string.settings_title)) }, navigationIcon = { BackButton { onNavigateBack() } }, ) }, @@ -59,6 +61,7 @@ fun SettingScreen( item { InformationRow() } item { Box(modifier = Modifier.height(15.dp)) } item { UploadLogSettingItem() } + item { AlbumIndexManagerUIItem(navigateToIndexMgr) } } } } @@ -105,13 +108,13 @@ private fun InformationRow() { verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = { launchURL(PRIVACY_URL) }) { - Text(text = "隐私政策") + Text(text = stringResource(R.string.privacy_policy)) } Divider() TextButton(onClick = { launchURL(SOURCE_REPO_URL) }) { - Icon(imageVector = Icons.Default.Code, contentDescription = "Github") + Icon(imageVector = Icons.Default.Code, contentDescription = stringResource(R.string.github)) Box(modifier = Modifier.width(5.dp)) - Text(text = "Github") + Text(text = stringResource(R.string.github)) } } } @@ -123,4 +126,19 @@ private fun Divider() { .height(20.dp) .padding(horizontal = 3.dp) ) +} + +@Composable +private fun AlbumIndexManagerUIItem(navigateToIndexMgr: () -> Unit) { + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.Dataset, + contentDescription = "Click to Manage Album Indexes" + ) + }, + headlineContent = { Text(text = stringResource(R.string.album_index_manager_ui_title)) }, + supportingContent = { Text(text = stringResource(R.string.album_index_manager_ui_desc)) }, + modifier = Modifier.clickable { navigateToIndexMgr() } + ) } \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 60f771c..ec8919f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -55,4 +55,27 @@ 查看隐私政策 使用外部应用打开 + + 设置 + 隐私政策 + Github + + 相册索引管理器 + 打开一个对话框来查看和删除已经索引的相册信息 + 索引管理器(点击删除对应索引) + 确认删除索引 + 您确定要删除此相册的索引吗?这将清除该相册的搜索索引。 + 确认 + 取消 + 此项目的索引已删除 + 请稍后 + 此相册似乎无效 + 此相册似乎需要更新 + + + 照片数量 + 日期 + 无效相册,点击删除索引 + 相册需要更新,点击删除索引 + 点击删除相册索引 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b8634d..16b2f25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,4 +53,28 @@ [External Storage] View privacy policy Open with External Apps + + + Settings + Privacy Policy + Github + + Album Index Manager + Open an Dialog to Review and Delete Album Index Data + Index Manager (Click to Delete Index) + Confirm Index Deletion + Are you sure you want to delete the index for this album? This will clear the search index for the album. + Confirm + Cancel + Index for this item has been deleted + Please wait + This album seems to be invalid + This album seems to need an update + + + Photo Count + Date + Invalid album, click to delete index + Album needs update, click to delete index + Click to delete album index \ No newline at end of file