adding skeleton for image gallery folder viewing

This commit is contained in:
Adam Brown 2022-09-28 22:37:53 +01:00
parent debfc5e5f0
commit 6f89c71300
2 changed files with 290 additions and 82 deletions

View File

@ -1,9 +1,9 @@
package app.dapk.st.home package app.dapk.st.home
import android.Manifest
import android.os.Bundle import android.os.Bundle
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope
import app.dapk.st.core.DapkActivity import app.dapk.st.core.*
import app.dapk.st.core.PermissionResult import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.module import app.dapk.st.design.components.Route
import app.dapk.st.core.viewModel import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.Toolbar import app.dapk.st.design.components.SpiderPage
import app.dapk.st.directory.DirectoryModule import app.dapk.st.directory.DirectoryModule
import app.dapk.st.home.gallery.FetchMediaFoldersUseCase 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.Folder
import app.dapk.st.home.gallery.Media
import app.dapk.st.login.LoginModule import app.dapk.st.login.LoginModule
import app.dapk.st.profile.ProfileModule import app.dapk.st.profile.ProfileModule
import app.dapk.st.viewmodel.DapkViewModel
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : DapkActivity() { class MainActivity : DapkActivity() {
@ -55,27 +58,31 @@ class MainActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val state = mutableStateOf(emptyList<Folder>()) val viewModel = ImageGalleryViewModel(
FetchMediaFoldersUseCase(contentResolver),
FetchMediaUseCase(contentResolver),
)
lifecycleScope.launch { // lifecycleScope.launch {
when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { // when (ensurePermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
PermissionResult.Denied -> { // PermissionResult.Denied -> {
} // }
//
PermissionResult.Granted -> { // PermissionResult.Granted -> {
state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders() // state.value = FetchMediaFoldersUseCase(contentResolver).fetchFolders()
} // }
//
PermissionResult.ShowRational -> { // PermissionResult.ShowRational -> {
//
} // }
} // }
// }
}
setContent { setContent {
Surface { Surface {
ImageGallery(state) ImageGalleryScreen(viewModel) {
finish()
}
} }
} }
@ -113,56 +120,215 @@ class MainActivity : DapkActivity() {
} }
} }
data class ImageGalleryState(
val page: SpiderPage<out ImageGalleryPage>,
)
sealed interface ImageGalleryPage {
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")
val files = Route<Files>("Files")
}
}
sealed interface ImageGalleryEvent
class ImageGalleryViewModel(
private val foldersUseCase: FetchMediaFoldersUseCase,
private val fetchMediaUseCase: FetchMediaUseCase,
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
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<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
}
}
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
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<ImageGalleryPage.Files> {
copy(content = Lce.Content(media))
}
}
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
}
@Composable @Composable
fun ImageGallery(state: State<List<Folder>>) { fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit) {
LifecycleEffect(onStart = {
viewModel.start()
})
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> 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) } var boxWidth by remember { mutableStateOf(IntSize.Zero) }
val localDensity = LocalDensity.current val localDensity = LocalDensity.current
val screenWidth = LocalConfiguration.current.screenWidthDp val screenWidth = LocalConfiguration.current.screenWidthDp
Column { Column {
Toolbar(title = "Send to Awesome Room", onNavigate = {})
val columns = when { val columns = when {
screenWidth > 600 -> 4 screenWidth > 600 -> 4
else -> 2 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( when (val content = state.content) {
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.5f)), is Lce.Loading -> {
startY = boxWidth.width.toFloat() * 0.5f, CenteredLoading()
endY = boxWidth.width.toFloat() }
) is Lce.Content -> {
LazyVerticalGrid(
Box(modifier = Modifier.matchParentSize().background(gradient)) columns = GridCells.Fixed(columns),
Row( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart).padding(4.dp), ) {
horizontalArrangement = Arrangement.SpaceBetween, items(content.value, key = { it.id }) {
verticalAlignment = Alignment.CenterVertically Box(modifier = Modifier.fillMaxWidth().padding(2.dp).onGloballyPositioned {
) { boxWidth = it.size
Text(it.title, fontSize = 13.sp, color = Color.White) }) {
Text(it.itemCount.toString(), fontSize = 11.sp, color = Color.White) 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()
} }
} }
} }

View File

@ -16,15 +16,14 @@ class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
) { ) {
suspend fun fetchFolders(): List<Folder> { suspend fun fetchFolders(): List<Folder> {
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED) return withContext(Dispatchers.IO) {
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?" val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC" 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<String, Folder>() val folders = mutableMapOf<String, Folder>()
val contentUri = Images.Media.EXTERNAL_CONTENT_URI val contentUri = Images.Media.EXTERNAL_CONTENT_URI
withContext(Dispatchers.IO) {
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor -> contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
@ -32,27 +31,14 @@ class FetchMediaFoldersUseCase(
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])) val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: "" val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])) val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) } val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
folder.incrementItemCount() 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++ _itemCount++
} }
} }
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<Media> {
return withContext(Dispatchers.IO) {
val media = mutableListOf<Media>()
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"