Sebastian Aigner
2 years ago
committed by
GitHub
12 changed files with 535 additions and 195 deletions
@ -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) |
||||
} |
||||
} |
@ -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() |
||||
} |
After Width: | Height: | Size: 4.3 MiB |
Loading…
Reference in new issue