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
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<Folder>())
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<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
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) }
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()
}
}
}

View File

@ -16,15 +16,14 @@ class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver,
) {
suspend fun fetchFolders(): List<Folder> {
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<String, Folder>()
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
withContext(Dispatchers.IO) {
val folders = mutableMapOf<String, Folder>()
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++
}
}
}
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"