Browse Source

ImageViewer: "Memories" view for ImageViewer, Stack-based Navigation, StateFlow-based Image Provider (#2789)

pull/2792/head
Sebastian Aigner 2 years ago committed by GitHub
parent
commit
8a635b6f96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 114
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt
  2. 77
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt
  3. 50
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt
  4. 67
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PhotoGallery.kt
  5. 19
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt
  6. 41
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt
  7. 96
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt
  8. 11
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt
  9. 233
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt
  10. 19
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/NavigationStack.kt
  11. 3
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt
  12. BIN
      experimental/examples/imageviewer/shared/src/commonMain/resources/dummy_map.png

114
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt

@ -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() }
LaunchedEffect(Unit) {
galleryScreenState.refresh(dependencies)
}
LaunchedEffect(Unit) {
externalEvents.collect {
when (it) {
ExternalImageViewerEvent.Foward -> galleryScreenState.nextImage()
ExternalImageViewerEvent.Back -> galleryScreenState.previousImage()
}
}
}
val photoGallery = remember { PhotoGallery(dependencies) }
val rootGalleryPage = GalleryPage(photoGallery, externalEvents)
val navigationStack = remember { NavigationStack<Page>(rootGalleryPage) }
Surface(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(
galleryScreenState.screen == ScreenState.Miniatures,
enter = fadeIn(),
exit = fadeOut()
) {
MainScreen(galleryScreenState, dependencies)
}
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()
}
)
}
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() },
)
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()
})
}
}
}
}
}

77
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt

@ -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
}

50
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt

@ -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 }
}
}

67
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PhotoGallery.kt

@ -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
}
}

19
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt

@ -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)
}
}

41
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt

@ -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
)
}
}
}

96
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt → experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt

@ -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)

11
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt

@ -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

233
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt

@ -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) })
}

19
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/NavigationStack.kt

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

3
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt

@ -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?,

BIN
experimental/examples/imageviewer/shared/src/commonMain/resources/dummy_map.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Loading…
Cancel
Save