diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index 5170101..d6c6734 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -1,9 +1,9 @@ package app.dapk.st.home -import android.Manifest import android.os.Bundle import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -13,7 +13,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,19 +29,23 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.lifecycleScope -import app.dapk.st.core.DapkActivity -import app.dapk.st.core.PermissionResult -import app.dapk.st.core.module -import app.dapk.st.core.viewModel -import app.dapk.st.design.components.Toolbar +import androidx.lifecycle.viewModelScope +import app.dapk.st.core.* +import app.dapk.st.core.components.CenteredLoading +import app.dapk.st.design.components.Route +import app.dapk.st.design.components.Spider +import app.dapk.st.design.components.SpiderPage import app.dapk.st.directory.DirectoryModule import app.dapk.st.home.gallery.FetchMediaFoldersUseCase +import app.dapk.st.home.gallery.FetchMediaUseCase import app.dapk.st.home.gallery.Folder +import app.dapk.st.home.gallery.Media import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule +import app.dapk.st.viewmodel.DapkViewModel import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest +import kotlinx.coroutines.Job import kotlinx.coroutines.launch class MainActivity : DapkActivity() { @@ -55,27 +58,31 @@ class MainActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val state = mutableStateOf(emptyList()) + val viewModel = ImageGalleryViewModel( + FetchMediaFoldersUseCase(contentResolver), + FetchMediaUseCase(contentResolver), + ) - lifecycleScope.launch { - when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { - PermissionResult.Denied -> { - } - - PermissionResult.Granted -> { - state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() - } - - PermissionResult.ShowRational -> { - - } - } - - } +// lifecycleScope.launch { +// when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { +// PermissionResult.Denied -> { +// } +// +// PermissionResult.Granted -> { +// state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() +// } +// +// PermissionResult.ShowRational -> { +// +// } +// } +// } setContent { Surface { - ImageGallery(state) + ImageGalleryScreen(viewModel) { + finish() + } } } @@ -113,56 +120,215 @@ class MainActivity : DapkActivity() { } } + +data class ImageGalleryState( + val page: SpiderPage, +) + + +sealed interface ImageGalleryPage { + data class Folders(val content: Lce>) : ImageGalleryPage + data class Files(val content: Lce>) : ImageGalleryPage + + object Routes { + val folders = Route("Folders") + val files = Route("Files") + } +} + + +sealed interface ImageGalleryEvent + +class ImageGalleryViewModel( + private val foldersUseCase: FetchMediaFoldersUseCase, + private val fetchMediaUseCase: FetchMediaUseCase, +) : DapkViewModel( + initialState = ImageGalleryState(page = SpiderPage(route = ImageGalleryPage.Routes.folders, "", null, ImageGalleryPage.Folders(Lce.Loading()))) +) { + + private var currentPageJob: Job? = null + + fun start() { + currentPageJob?.cancel() + currentPageJob = viewModelScope.launch { + val folders = foldersUseCase.fetchFolders() + updatePageState { copy(content = Lce.Content(folders)) } + } + + } + + fun goTo(page: SpiderPage) { + currentPageJob?.cancel() + updateState { copy(page = page) } + } + + fun selectFolder(folder: Folder) { + currentPageJob?.cancel() + + updateState { + copy( + page = SpiderPage( + route = ImageGalleryPage.Routes.files, + label = page.label, + parent = ImageGalleryPage.Routes.folders, + state = ImageGalleryPage.Files(Lce.Loading()) + ) + ) + } + + currentPageJob = viewModelScope.launch { + val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId) + updatePageState { + copy(content = Lce.Content(media)) + } + } + } + + @Suppress("UNCHECKED_CAST") + private inline fun updatePageState(crossinline block: S.() -> S) { + val page = state.page + val currentState = page.state + require(currentState is S) + updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } + } + +} + @Composable -fun ImageGallery(state: State>) { +fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit) { + LifecycleEffect(onStart = { + viewModel.start() + }) + + val onNavigate: (SpiderPage?) -> Unit = { + when (it) { + null -> onTopLevelBack() + else -> viewModel.goTo(it) + } + } + + Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) { + item(ImageGalleryPage.Routes.folders) { + ImageGalleryFolders(it) { folder -> + viewModel.selectFolder(folder) + } + } + item(ImageGalleryPage.Routes.files) { + ImageGalleryMedia(it) + } + } + +} + +@Composable +fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit) { + var boxWidth by remember { mutableStateOf(IntSize.Zero) } + val localDensity = LocalDensity.current + val screenWidth = LocalConfiguration.current.screenWidthDp + + when (val content = state.content) { + is Lce.Loading -> { + CenteredLoading() + } + + is Lce.Content -> { + Column { + val columns = when { + screenWidth > 600 -> 4 + else -> 2 + } + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + ) { + items(content.value, key = { it.bucketId }) { + Box(modifier = Modifier.fillMaxWidth().padding(2.dp) + .clickable { onClick(it) } + .onGloballyPositioned { + boxWidth = it.size + }) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it.thumbnail.toString()) + .build(), + ), + contentDescription = "123", + modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + contentScale = ContentScale.Crop + ) + + val gradient = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), + startY = boxWidth.width.toFloat() * 0.5f, + endY = boxWidth.width.toFloat() + ) + + Box(modifier = Modifier.matchParentSize().background(gradient)) + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(it.title, fontSize = 13.sp, color = Color.White) + Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White) + } + } + } + } + } + } + + is Lce.Error -> TODO() + } +} + +@Composable +fun ImageGalleryMedia(state: ImageGalleryPage.Files) { var boxWidth by remember { mutableStateOf(IntSize.Zero) } val localDensity = LocalDensity.current val screenWidth = LocalConfiguration.current.screenWidthDp Column { - Toolbar(title = "Send to Awesome Room", onNavigate = {}) val columns = when { screenWidth > 600 -> 4 else -> 2 } - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = Modifier.fillMaxSize(), - ) { - items(state.value, key = { it.bucketId }) { - Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned { - boxWidth = it.size - }) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalContext.current) - .data(it.thumbnail.toString()) - .build(), - ), - contentDescription = "123", - modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), - contentScale = ContentScale.Crop - ) - val gradient = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), - startY = boxWidth.width.toFloat() * 0.5f, - endY = boxWidth.width.toFloat() - ) - - Box(modifier = Modifier.matchParentSize().background(gradient)) - Row( - modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(it.title, fontSize = 13.sp, color = Color.White) - Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White) + when (val content = state.content) { + is Lce.Loading -> { + CenteredLoading() + } + is Lce.Content -> { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + ) { + items(content.value, key = { it.id }) { + Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned { + boxWidth = it.size + }) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it.uri.toString()) + .crossfade(true) + .build(), + ), + contentDescription = "123", + modifier = Modifier.fillMaxWidth().height(with(localDensity) { boxWidth.width.toDp() }), + contentScale = ContentScale.Crop + ) + } } - } } + + is Lce.Error -> TODO() } + } } + + diff --git a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt index 4333753..d4d0e66 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/gallery/FetchMediaFoldersUseCase.kt @@ -16,15 +16,14 @@ class FetchMediaFoldersUseCase( private val contentResolver: ContentResolver, ) { - suspend fun fetchFolders(): List { - val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED) - val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?" - val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC" + return withContext(Dispatchers.IO) { + val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED) + val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?" + val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC" - val folders = mutableMapOf() - val contentUri = Images.Media.EXTERNAL_CONTENT_URI - withContext(Dispatchers.IO) { + val folders = mutableMapOf() + val contentUri = Images.Media.EXTERNAL_CONTENT_URI contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor -> while (cursor != null && cursor.moveToNext()) { val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) @@ -32,27 +31,14 @@ class FetchMediaFoldersUseCase( val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])) val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: "" val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])) - val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) } folder.incrementItemCount() - -// val folder: FolderData = Util.getOrDefault(folders, bucketId, FolderData(thumbnail, localizeTitle(context, title), bucketId)) -// folder.incrementCount() -// folders.put(bucketId, folder) -// if (cameraBucketId == null && title == "Camera") { -// cameraBucketId = bucketId -// } -// if (timestamp > thumbnailTimestamp) { -// globalThumbnail = thumbnail -// thumbnailTimestamp = timestamp -// } } } + folders.values.toList() } - return folders.values.toList() } - private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" } @@ -69,4 +55,60 @@ data class Folder( _itemCount++ } -} \ No newline at end of file +} + + +class FetchMediaUseCase(private val contentResolver: ContentResolver) { + + private val projection = arrayOf( + Images.Media._ID, + Images.Media.MIME_TYPE, + Images.Media.DATE_MODIFIED, + Images.Media.ORIENTATION, + Images.Media.WIDTH, + Images.Media.HEIGHT, + Images.Media.SIZE + ) + + suspend fun getMediaInBucket(bucketId: String): List { + return withContext(Dispatchers.IO) { + + val media = mutableListOf() + val selection = Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + Images.Media.MIME_TYPE + " NOT LIKE ?" + val selectionArgs = arrayOf(bucketId, "%image/svg%") + val sortBy = Images.Media.DATE_MODIFIED + " DESC" + val contentUri = Images.Media.EXTERNAL_CONTENT_URI + contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor -> + while (cursor != null && cursor.moveToNext()) { + val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) + val uri = ContentUris.withAppendedId(contentUri, rowId) + val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)) + val date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)) + val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) + val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))) + val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))) + val size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)) + media.add(Media(rowId, uri, mimetype, width, height, size, date)) + } + } + media + } + } + + private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.WIDTH else Images.Media.HEIGHT + + private fun getHeightColumn(orientation: Int) = if (orientation == 0 || orientation == 180) Images.Media.HEIGHT else Images.Media.WIDTH + +} + +data class Media( + val id: Long, + val uri: Uri, + val mimeType: String, + val width: Int, + val height: Int, + val size: Long, + val dateModifiedEpochMillis: Long, +) + +private fun isNotPending() = if (Build.VERSION.SDK_INT <= 28) Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"