mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-03-10 08:11:20 +00:00
ImageViewer: "Memories" view for ImageViewer, Stack-based Navigation, StateFlow-based Image Provider (#2789)
This commit is contained in:
@@ -1,21 +1,30 @@
|
||||
package example.imageviewer
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import example.imageviewer.model.GalleryScreenState
|
||||
import example.imageviewer.model.ScreenState
|
||||
import example.imageviewer.model.CameraPage
|
||||
import example.imageviewer.model.FullScreenPage
|
||||
import example.imageviewer.model.GalleryPage
|
||||
import example.imageviewer.model.PhotoGallery
|
||||
import example.imageviewer.model.MemoryPage
|
||||
import example.imageviewer.model.Page
|
||||
import example.imageviewer.model.bigUrl
|
||||
import example.imageviewer.view.CameraScreen
|
||||
import example.imageviewer.view.FullscreenImage
|
||||
import example.imageviewer.view.MainScreen
|
||||
import example.imageviewer.view.GalleryScreen
|
||||
import example.imageviewer.view.MemoryScreen
|
||||
import example.imageviewer.view.NavigationStack
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
@@ -24,49 +33,72 @@ enum class ExternalImageViewerEvent {
|
||||
Back
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun ImageViewerCommon(
|
||||
dependencies: Dependencies,
|
||||
externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow()
|
||||
) {
|
||||
val galleryScreenState = remember { GalleryScreenState() }
|
||||
val photoGallery = remember { PhotoGallery(dependencies) }
|
||||
val rootGalleryPage = GalleryPage(photoGallery, externalEvents)
|
||||
val navigationStack = remember { NavigationStack<Page>(rootGalleryPage) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
galleryScreenState.refresh(dependencies)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
externalEvents.collect {
|
||||
when (it) {
|
||||
ExternalImageViewerEvent.Foward -> galleryScreenState.nextImage()
|
||||
ExternalImageViewerEvent.Back -> galleryScreenState.previousImage()
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedContent(targetState = navigationStack.lastWithIndex(), transitionSpec = {
|
||||
val previousIdx = initialState.index
|
||||
val currentIdx = targetState.index
|
||||
val multiplier = if (previousIdx < currentIdx) 1 else -1
|
||||
slideInHorizontally { w -> multiplier * w } with
|
||||
slideOutHorizontally { w -> multiplier * -1 * w }
|
||||
}) { (index, page) ->
|
||||
when (page) {
|
||||
is GalleryPage -> {
|
||||
GalleryScreen(
|
||||
page,
|
||||
photoGallery,
|
||||
dependencies,
|
||||
onClickPreviewPicture = { previewPictureId ->
|
||||
navigationStack.push(MemoryPage(previewPictureId))
|
||||
},
|
||||
onMakeNewMemory = {
|
||||
navigationStack.push(CameraPage())
|
||||
})
|
||||
}
|
||||
|
||||
is FullScreenPage -> {
|
||||
FullscreenImage(
|
||||
galleryId = page.galleryId,
|
||||
gallery = photoGallery,
|
||||
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) },
|
||||
getFilter = { dependencies.getFilter(it) },
|
||||
localization = dependencies.localization,
|
||||
back = {
|
||||
navigationStack.back()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MemoryPage -> {
|
||||
MemoryScreen(
|
||||
page,
|
||||
photoGallery,
|
||||
onSelectRelatedMemory = { galleryId ->
|
||||
navigationStack.push(MemoryPage(galleryId))
|
||||
},
|
||||
onBack = {
|
||||
navigationStack.back()
|
||||
},
|
||||
onHeaderClick = { galleryId ->
|
||||
navigationStack.push(FullScreenPage(galleryId))
|
||||
})
|
||||
}
|
||||
|
||||
is CameraPage -> {
|
||||
CameraScreen(onBack = {
|
||||
navigationStack.back()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedVisibility(
|
||||
galleryScreenState.screen == ScreenState.Miniatures,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
MainScreen(galleryScreenState, dependencies)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
galleryScreenState.screen == ScreenState.FullScreen,
|
||||
enter = slideInHorizontally { -it },
|
||||
exit = slideOutHorizontally { -it }) {
|
||||
FullscreenImage(
|
||||
picture = galleryScreenState.picture,
|
||||
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) },
|
||||
getFilter = { dependencies.getFilter(it) },
|
||||
localization = dependencies.localization,
|
||||
back = {
|
||||
galleryScreenState.screen = ScreenState.Miniatures
|
||||
},
|
||||
nextImage = { galleryScreenState.nextImage() },
|
||||
previousImage = { galleryScreenState.previousImage() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package example.imageviewer.model
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import example.imageviewer.Dependencies
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
|
||||
data class PictureWithThumbnail(val picture: Picture, val thumbnail: ImageBitmap)
|
||||
|
||||
class GalleryScreenState {
|
||||
var currentPictureIndex by mutableStateOf(0)
|
||||
val picturesWithThumbnail = mutableStateListOf<PictureWithThumbnail>()
|
||||
var screen by mutableStateOf<ScreenState>(ScreenState.Miniatures)
|
||||
val isContentReady get() = picturesWithThumbnail.isNotEmpty()
|
||||
|
||||
val picture get(): Picture? = picturesWithThumbnail.getOrNull(currentPictureIndex)?.picture
|
||||
|
||||
fun nextImage() {
|
||||
currentPictureIndex = (currentPictureIndex + 1).mod(picturesWithThumbnail.lastIndex)
|
||||
}
|
||||
|
||||
fun previousImage() {
|
||||
currentPictureIndex = (currentPictureIndex - 1).mod(picturesWithThumbnail.lastIndex)
|
||||
}
|
||||
|
||||
fun selectPicture(picture: Picture) {
|
||||
currentPictureIndex = picturesWithThumbnail.indexOfFirst { it.picture == picture }
|
||||
}
|
||||
|
||||
fun toFullscreen(idx: Int = currentPictureIndex) {
|
||||
currentPictureIndex = idx
|
||||
screen = ScreenState.FullScreen
|
||||
}
|
||||
|
||||
fun refresh(dependencies: Dependencies) {
|
||||
dependencies.ioScope.launch {
|
||||
try {
|
||||
val pictures = dependencies.json.decodeFromString(
|
||||
ListSerializer(Picture.serializer()),
|
||||
dependencies.httpClient.get(PICTURES_DATA_URL).bodyAsText()
|
||||
)
|
||||
val miniatures = pictures
|
||||
.map { picture ->
|
||||
async {
|
||||
picture to dependencies.imageRepository.loadContent(picture.smallUrl)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.map { (pic, bit) -> PictureWithThumbnail(pic, bit) }
|
||||
|
||||
picturesWithThumbnail.clear()
|
||||
picturesWithThumbnail.addAll(miniatures)
|
||||
} catch (e: CancellationException) {
|
||||
println("Rethrowing CancellationException with original cause")
|
||||
// https://kotlinlang.org/docs/exception-handling.html#exceptions-aggregation
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
dependencies.notification.notifyNoInternet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ScreenState {
|
||||
object Miniatures : ScreenState
|
||||
object FullScreen : ScreenState
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package example.imageviewer.model
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import example.imageviewer.ExternalImageViewerEvent
|
||||
import example.imageviewer.view.GalleryStyle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
sealed class Page
|
||||
|
||||
class MemoryPage(val galleryId: GalleryId) : Page() {
|
||||
val scrollState = ScrollState(0)
|
||||
}
|
||||
|
||||
class CameraPage : Page()
|
||||
|
||||
class FullScreenPage(val galleryId: GalleryId) : Page()
|
||||
|
||||
class GalleryPage(
|
||||
val photoGallery: PhotoGallery,
|
||||
val externalEvents: Flow<ExternalImageViewerEvent>
|
||||
) : Page() {
|
||||
var galleryStyle by mutableStateOf(GalleryStyle.SQUARES)
|
||||
|
||||
fun toggleGalleryStyle() {
|
||||
galleryStyle = if(galleryStyle == GalleryStyle.SQUARES) GalleryStyle.LIST else GalleryStyle.SQUARES
|
||||
}
|
||||
|
||||
var currentPictureIndex by mutableStateOf(0)
|
||||
|
||||
val picture get(): Picture? = photoGallery.galleryStateFlow.value.getOrNull(currentPictureIndex)?.picture
|
||||
|
||||
val pictureId get(): GalleryId? = photoGallery.galleryStateFlow.value.getOrNull(currentPictureIndex)?.id
|
||||
|
||||
fun nextImage() {
|
||||
currentPictureIndex =
|
||||
(currentPictureIndex + 1).mod(photoGallery.galleryStateFlow.value.lastIndex)
|
||||
}
|
||||
|
||||
fun previousImage() {
|
||||
currentPictureIndex =
|
||||
(currentPictureIndex - 1).mod(photoGallery.galleryStateFlow.value.lastIndex)
|
||||
}
|
||||
|
||||
fun selectPicture(galleryId: GalleryId) {
|
||||
currentPictureIndex = photoGallery.galleryStateFlow.value.indexOfFirst { it.id == galleryId }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package example.imageviewer.model
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import example.imageviewer.Dependencies
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
|
||||
@JvmInline
|
||||
value class GalleryId(val l: Long)
|
||||
data class GalleryEntryWithMetadata(
|
||||
val id: GalleryId,
|
||||
val picture: Picture,
|
||||
val thumbnail: ImageBitmap,
|
||||
)
|
||||
|
||||
class PhotoGallery(val deps: Dependencies) {
|
||||
private val _galleryStateFlow = MutableStateFlow<List<GalleryEntryWithMetadata>>(listOf())
|
||||
val galleryStateFlow: StateFlow<List<GalleryEntryWithMetadata>> = _galleryStateFlow
|
||||
|
||||
init {
|
||||
updatePictures()
|
||||
}
|
||||
|
||||
fun updatePictures() {
|
||||
deps.ioScope.launch {
|
||||
try {
|
||||
val pics = getNewPictures(deps)
|
||||
_galleryStateFlow.emit(pics)
|
||||
} catch (e: CancellationException) {
|
||||
println("Rethrowing CancellationException with original cause")
|
||||
// https://kotlinlang.org/docs/exception-handling.html#exceptions-aggregation
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
deps.notification.notifyNoInternet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getNewPictures(dependencies: Dependencies): List<GalleryEntryWithMetadata> {
|
||||
val pictures = dependencies.json.decodeFromString(
|
||||
ListSerializer(Picture.serializer()),
|
||||
dependencies.httpClient.get(PICTURES_DATA_URL).bodyAsText()
|
||||
)
|
||||
val miniatures = pictures
|
||||
.map { picture ->
|
||||
dependencies.ioScope.async {
|
||||
picture to dependencies.imageRepository.loadContent(picture.smallUrl)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.mapIndexed { index, pictureAndBitmap ->
|
||||
val (pic, bit) = pictureAndBitmap
|
||||
GalleryEntryWithMetadata(GalleryId(index.toLong()), pic, bit)
|
||||
}
|
||||
return miniatures
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
@Composable
|
||||
internal fun CameraScreen(onBack: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black).clickable { onBack() }, contentAlignment = Alignment.Center) {
|
||||
Text("Nothing here yet 📸", textAlign = TextAlign.Center, color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -31,20 +27,20 @@ import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
internal fun FullscreenImage(
|
||||
picture: Picture?,
|
||||
galleryId: GalleryId?,
|
||||
gallery: PhotoGallery,
|
||||
getImage: suspend (Picture) -> ImageBitmap,
|
||||
getFilter: (FilterType) -> BitmapFilter,
|
||||
localization: Localization,
|
||||
back: () -> Unit,
|
||||
nextImage: () -> Unit,
|
||||
previousImage: () -> Unit,
|
||||
) {
|
||||
val picture = gallery.galleryStateFlow.value.first { it.id == galleryId }.picture
|
||||
val availableFilters = FilterType.values().toList()
|
||||
var selectedFilters by remember { mutableStateOf(emptySet<FilterType>()) }
|
||||
|
||||
val originalImageState = remember(picture) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(picture) {
|
||||
if (picture != null) {
|
||||
val originalImageState = remember(galleryId) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(galleryId) {
|
||||
if (galleryId != null) {
|
||||
originalImageState.value = getImage(picture)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +61,7 @@ internal fun FullscreenImage(
|
||||
Column {
|
||||
FullscreenImageBar(
|
||||
localization,
|
||||
picture?.name,
|
||||
picture.name,
|
||||
back,
|
||||
availableFilters,
|
||||
selectedFilters,
|
||||
@@ -107,29 +103,6 @@ internal fun FullscreenImage(
|
||||
LoadingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
|
||||
containerColor = ImageviewerColors.KotlinGradient0,
|
||||
onClick = previousImage
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Previous",
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(10.dp),
|
||||
containerColor = ImageviewerColors.KotlinGradient0,
|
||||
onClick = nextImage
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Next",
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,10 +27,9 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,9 +41,11 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import example.imageviewer.Dependencies
|
||||
import example.imageviewer.model.GalleryScreenState
|
||||
import example.imageviewer.model.Picture
|
||||
import example.imageviewer.model.PictureWithThumbnail
|
||||
import example.imageviewer.ExternalImageViewerEvent
|
||||
import example.imageviewer.model.GalleryEntryWithMetadata
|
||||
import example.imageviewer.model.GalleryId
|
||||
import example.imageviewer.model.GalleryPage
|
||||
import example.imageviewer.model.PhotoGallery
|
||||
import example.imageviewer.model.bigUrl
|
||||
import example.imageviewer.style.ImageviewerColors
|
||||
import example.imageviewer.style.ImageviewerColors.kotlinHorizontalGradientBrush
|
||||
@@ -71,70 +72,87 @@ enum class GalleryStyle {
|
||||
LIST
|
||||
}
|
||||
|
||||
fun GalleryStyle.toggled(): GalleryStyle {
|
||||
return if (this == GalleryStyle.SQUARES) GalleryStyle.LIST else GalleryStyle.SQUARES
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MainScreen(galleryScreenState: GalleryScreenState, dependencies: Dependencies) {
|
||||
var galleryStyle by remember { mutableStateOf(GalleryStyle.SQUARES) }
|
||||
internal fun GalleryScreen(
|
||||
galleryPage: GalleryPage,
|
||||
photoGallery: PhotoGallery,
|
||||
dependencies: Dependencies,
|
||||
onClickPreviewPicture: (GalleryId) -> Unit,
|
||||
onMakeNewMemory: () -> Unit
|
||||
) {
|
||||
val pictures by photoGallery.galleryStateFlow.collectAsState()
|
||||
LaunchedEffect(Unit) {
|
||||
galleryPage.externalEvents.collect {
|
||||
when (it) {
|
||||
ExternalImageViewerEvent.Foward -> galleryPage.nextImage()
|
||||
ExternalImageViewerEvent.Back -> galleryPage.previousImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
TitleBar(
|
||||
onRefresh = { galleryScreenState.refresh(dependencies) },
|
||||
onToggle = { galleryStyle = galleryStyle.toggled() },
|
||||
onRefresh = { photoGallery.updatePictures() },
|
||||
onToggle = { galleryPage.toggleGalleryStyle() },
|
||||
dependencies
|
||||
)
|
||||
if (needShowPreview()) {
|
||||
PreviewImage(
|
||||
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) },
|
||||
picture = galleryScreenState.picture, onClick = {
|
||||
galleryScreenState.toFullscreen()
|
||||
picture = galleryPage.picture, onClick = {
|
||||
galleryPage.pictureId?.let(onClickPreviewPicture)
|
||||
})
|
||||
}
|
||||
when (galleryStyle) {
|
||||
when (galleryPage.galleryStyle) {
|
||||
GalleryStyle.SQUARES -> SquaresGalleryView(
|
||||
galleryScreenState.picturesWithThumbnail,
|
||||
galleryScreenState.picturesWithThumbnail.getOrNull(galleryScreenState.currentPictureIndex),
|
||||
onSelect = { galleryScreenState.selectPicture(it) }
|
||||
pictures,
|
||||
galleryPage.pictureId,
|
||||
onSelect = { galleryPage.selectPicture(it) },
|
||||
onMakeNewMemory
|
||||
)
|
||||
|
||||
GalleryStyle.LIST -> ListGalleryView(
|
||||
galleryScreenState.picturesWithThumbnail,
|
||||
pictures,
|
||||
dependencies,
|
||||
onSelect = { galleryScreenState.selectPicture(it) },
|
||||
onFullScreen = { galleryScreenState.toFullscreen(it) }
|
||||
onSelect = { galleryPage.selectPicture(it) },
|
||||
onFullScreen = { onClickPreviewPicture(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!galleryScreenState.isContentReady) {
|
||||
if (pictures.isEmpty()) {
|
||||
LoadingScreen(dependencies.localization.loading)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SquaresGalleryView(
|
||||
images: List<PictureWithThumbnail>,
|
||||
selectedImage: PictureWithThumbnail?,
|
||||
onSelect: (Picture) -> Unit
|
||||
images: List<GalleryEntryWithMetadata>,
|
||||
selectedImage: GalleryId?,
|
||||
onSelect: (GalleryId) -> Unit,
|
||||
onMakeNewMemory: () -> Unit,
|
||||
) {
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
|
||||
item {
|
||||
MakeNewMemoryMiniature()
|
||||
MakeNewMemoryMiniature(onMakeNewMemory)
|
||||
}
|
||||
itemsIndexed(images) { idx, image ->
|
||||
val isSelected = image == selectedImage
|
||||
val isSelected = image.id == selectedImage
|
||||
val (picture, bitmap) = image
|
||||
SquareMiniature(bitmap, onClick = { onSelect(picture) }, isHighlighted = isSelected)
|
||||
SquareMiniature(
|
||||
image.thumbnail,
|
||||
onClick = { onSelect(picture) },
|
||||
isHighlighted = isSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MakeNewMemoryMiniature() {
|
||||
private fun MakeNewMemoryMiniature(onClick: () -> Unit) {
|
||||
Box(
|
||||
Modifier.aspectRatio(1.0f)
|
||||
.clickable {
|
||||
// TODO: Open Camera!
|
||||
onClick()
|
||||
}, contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
@@ -148,7 +166,7 @@ private fun MakeNewMemoryMiniature() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick: () -> Unit) {
|
||||
internal fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick: () -> Unit) {
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = null,
|
||||
@@ -163,10 +181,10 @@ private fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick:
|
||||
|
||||
@Composable
|
||||
private fun ListGalleryView(
|
||||
pictures: List<PictureWithThumbnail>,
|
||||
pictures: List<GalleryEntryWithMetadata>,
|
||||
dependencies: Dependencies,
|
||||
onSelect: (Picture) -> Unit,
|
||||
onFullScreen: (Int) -> Unit
|
||||
onSelect: (GalleryId) -> Unit,
|
||||
onFullScreen: (GalleryId) -> Unit
|
||||
) {
|
||||
GalleryHeader()
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
@@ -174,15 +192,15 @@ private fun ListGalleryView(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
for ((idx, picWithThumb) in pictures.withIndex()) {
|
||||
val (picture, miniature) = picWithThumb
|
||||
val (galleryId, picture, miniature) = picWithThumb
|
||||
Miniature(
|
||||
picture = picture,
|
||||
image = miniature,
|
||||
onClickSelect = {
|
||||
onSelect(picture)
|
||||
onSelect(galleryId)
|
||||
},
|
||||
onClickFullScreen = {
|
||||
onFullScreen(idx)
|
||||
onFullScreen(galleryId)
|
||||
},
|
||||
onClickInfo = {
|
||||
dependencies.notification.notifyImageData(picture)
|
||||
@@ -1,9 +1,16 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import example.imageviewer.model.GalleryEntryWithMetadata
|
||||
import example.imageviewer.model.GalleryId
|
||||
import example.imageviewer.model.MemoryPage
|
||||
import example.imageviewer.model.PhotoGallery
|
||||
import example.imageviewer.style.ImageviewerColors
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun MemoryScreen(
|
||||
memoryPage: MemoryPage,
|
||||
photoGallery: PhotoGallery,
|
||||
onSelectRelatedMemory: (GalleryId) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onHeaderClick: (GalleryId) -> Unit
|
||||
) {
|
||||
val pictures by photoGallery.galleryStateFlow.collectAsState()
|
||||
val picture = pictures.first { it.id == memoryPage.galleryId }
|
||||
Column {
|
||||
TopAppBar(
|
||||
modifier = Modifier.background(brush = ImageviewerColors.kotlinHorizontalGradientBrush),
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||
containerColor = ImageviewerColors.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
title = {
|
||||
Text("")
|
||||
},
|
||||
navigationIcon = {
|
||||
Tooltip("Back") {
|
||||
Image(
|
||||
painterResource("back.png"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { onBack() }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
val scrollState = memoryPage.scrollState
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(Color.White)
|
||||
.graphicsLayer {
|
||||
translationY = 0.5f * scrollState.value
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MemoryHeader(picture.thumbnail, onClick = { onHeaderClick(memoryPage.galleryId) })
|
||||
}
|
||||
Box(modifier = Modifier.background(ImageviewerColors.kotlinHorizontalGradientBrush)) {
|
||||
Column {
|
||||
Headliner("Where it happened")
|
||||
LocationVisualizer()
|
||||
Headliner("What happened")
|
||||
Collapsible(
|
||||
"""
|
||||
I took a picture with my iPhone 14 at 17:45. The picture ended up being 3024 x 4032 pixels. ✨
|
||||
|
||||
I took multiple additional photos of the same subject, but they turned out not quite as well, so I decided to keep this specific one as a memory.
|
||||
|
||||
I might upload this picture to Unsplash at some point, since other people might also enjoy this picture. So it would make sense to not keep it to myself! 😄
|
||||
""".trimIndent()
|
||||
)
|
||||
Headliner("Related memories")
|
||||
RelatedMemoriesVisualizer(pictures, onSelectRelatedMemory)
|
||||
Spacer(Modifier.height(50.dp))
|
||||
Text(
|
||||
"Delete this memory",
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(Modifier.height(50.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemoryHeader(bitmap: ImageBitmap, onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(modifier = Modifier.clickable(interactionSource, null, onClick = { onClick() })) {
|
||||
Image(
|
||||
bitmap,
|
||||
"Memory",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Text(
|
||||
"Your Memory",
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
fontSize = 50.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = Color.Black,
|
||||
shape = RoundedCornerShape(100.dp)
|
||||
)
|
||||
.clip(
|
||||
RoundedCornerShape(100.dp)
|
||||
)
|
||||
.background(Color.Black.copy(alpha = 0.7f)).padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"19th of April 2023",
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Collapsible(s: String) {
|
||||
val interctionSource = remember { MutableInteractionSource() }
|
||||
var isCollapsed by remember { mutableStateOf(true) }
|
||||
val text = if (isCollapsed) s.lines().first() + "... (see more)" else s
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.padding(10.dp, 0.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.White)
|
||||
.padding(10.dp)
|
||||
.animateContentSize()
|
||||
.clickable(interactionSource = interctionSource, indication = null) {
|
||||
isCollapsed = !isCollapsed
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Headliner(s: String) {
|
||||
Text(
|
||||
text = s,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(10.dp, 30.dp, 10.dp, 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
@Composable
|
||||
internal fun LocationVisualizer() {
|
||||
Image(
|
||||
painterResource("dummy_map.png"),
|
||||
"Map",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().height(200.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RelatedMemoriesVisualizer(
|
||||
ps: List<GalleryEntryWithMetadata>,
|
||||
onSelectRelatedMemory: (GalleryId) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(10.dp, 0.dp).clip(RoundedCornerShape(10.dp)).fillMaxWidth()
|
||||
.height(200.dp)
|
||||
) {
|
||||
LazyRow(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(ps) { idx, item ->
|
||||
RelatedMemory(idx, item, onSelectRelatedMemory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RelatedMemory(
|
||||
index: Int,
|
||||
galleryEntry: GalleryEntryWithMetadata,
|
||||
onSelectRelatedMemory: (GalleryId) -> Unit
|
||||
) {
|
||||
SquareMiniature(
|
||||
galleryEntry.thumbnail,
|
||||
false,
|
||||
onClick = { onSelectRelatedMemory(galleryEntry.id) })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
class NavigationStack<T>(initial: T) {
|
||||
private val stack = mutableStateListOf(initial)
|
||||
fun push(t: T) {
|
||||
stack.add(t)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
if(stack.size > 1) {
|
||||
// Always keep one element on the view stack
|
||||
stack.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
fun lastWithIndex() = stack.withIndex().last()
|
||||
}
|
||||
@@ -27,9 +27,8 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import example.imageviewer.model.Picture
|
||||
import example.imageviewer.style.ImageviewerColors.kotlinHorizontalGradientBrush
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class, ExperimentalAnimationApi::class)
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun PreviewImage(
|
||||
picture: Picture?,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
Reference in New Issue
Block a user