Browse Source
* Design changes; move to material3 * Use animations to move between different images * More design changes, rounded corners and animations * Introduce square gallery view, start with granularizing state management * Introduce square gallery view, start with granularizing state management * Make PreviewImage not depend on the whole gallery state * Move in initialization logic from composition into launched effect * Highlight currently selected image * Hoist state for FullscreenImage TopAppBar Move from Custom Implementation to Material App Bar, use color scheme from main page Extract hardcoded colors to ImageViewerColors * Provide floating action buttons with nicer colors * Provide keyboard events via SharedFlow (remove passing around MutableState in the composable hierarchy as it may potentially violate UDF) Commonize IOScope initialization * Provide German translation in shared R-strings * Move from immutable data classes to Compose-aware State Holders. * Fix gradlew formatting issue? * Regenerate gradle wrapper after Android Studio autoformatting debacle * Resolve rememberCoroutineScope issue * Provide mock name for remaining picture in repo * Restore TEAM_ID in project.pbxproj * Use emptyFlow as default to simplify nullability handling for external events * Remove extraneous newline and unnecessary print statement * Provide German translation in XML format Consistently rename title to "My Memories" * Remove commented-out code, cleanup rendundant modifiers Make Title Bar use callbacks instead of accessing ViewModel directly Add toggle & icon for list and grid viewpull/2761/head
Sebastian Aigner
2 years ago
committed by
GitHub
38 changed files with 908 additions and 545 deletions
@ -1,11 +1,8 @@ |
|||||||
package example.imageviewer |
package example.imageviewer |
||||||
|
|
||||||
import androidx.compose.material.MaterialTheme |
|
||||||
import androidx.compose.ui.window.application |
import androidx.compose.ui.window.application |
||||||
import example.imageviewer.view.ImageViewerDesktop |
import example.imageviewer.view.ImageViewerDesktop |
||||||
|
|
||||||
fun main() = application { |
fun main() = application { |
||||||
MaterialTheme { |
ImageViewerDesktop() |
||||||
ImageViewerDesktop() |
|
||||||
} |
|
||||||
} |
} |
||||||
|
Binary file not shown.
@ -1,5 +1,6 @@ |
|||||||
distributionBase=GRADLE_USER_HOME |
distributionBase=GRADLE_USER_HOME |
||||||
distributionPath=wrapper/dists |
distributionPath=wrapper/dists |
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip |
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip |
||||||
|
networkTimeout=10000 |
||||||
zipStoreBase=GRADLE_USER_HOME |
zipStoreBase=GRADLE_USER_HOME |
||||||
zipStorePath=wrapper/dists |
zipStorePath=wrapper/dists |
||||||
|
@ -0,0 +1,7 @@ |
|||||||
|
package example.imageviewer.utils |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.rememberCoroutineScope |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
|
||||||
|
actual val ioDispatcher = Dispatchers.IO |
@ -1,8 +1,7 @@ |
|||||||
package example.imageviewer.view |
package example.imageviewer.view |
||||||
|
|
||||||
import androidx.compose.runtime.MutableState |
|
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import example.imageviewer.model.ScalableState |
import example.imageviewer.model.ScalableState |
||||||
|
|
||||||
actual fun Modifier.addUserInput(state: MutableState<ScalableState>) = |
actual fun Modifier.addUserInput(state: ScalableState) = |
||||||
addTouchUserInput(state) |
addTouchUserInput(state) |
||||||
|
@ -0,0 +1,17 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<resources> |
||||||
|
<string name="app_name">ImageViewer</string> |
||||||
|
<string name="loading">Bilder werden geladen...</string> |
||||||
|
<string name="repo_empty">Bildverzeichnis ist leer.</string> |
||||||
|
<string name="no_internet">Kein Internetzugriff.</string> |
||||||
|
<string name="repo_invalid">Bildverzeichnis beschädigt oder leer.</string> |
||||||
|
<string name="refresh_unavailable">Kann Bilder nicht aktualisieren.</string> |
||||||
|
<string name="load_image_unavailable">Kann volles Bild nicht laden.</string> |
||||||
|
<string name="last_image">Dies ist das letzte Bild.</string> |
||||||
|
<string name="first_image">Dies ist das erste Bild.</string> |
||||||
|
<string name="picture">Bild:</string> |
||||||
|
<string name="size">Abmessungen:</string> |
||||||
|
<string name="pixels">Pixel.</string> |
||||||
|
<string name="back">Zurück</string> |
||||||
|
<string name="refresh">Aktualisieren</string> |
||||||
|
</resources> |
@ -1,16 +0,0 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
|
||||||
<resources> |
|
||||||
<string name="app_name">ImageViewer</string> |
|
||||||
<string name="loading">Загружаем изображения...</string> |
|
||||||
<string name="repo_empty">Репозиторий пуст.</string> |
|
||||||
<string name="no_internet">Нет доступа в интернет.</string> |
|
||||||
<string name="repo_invalid">Список изображений в репозитории пуст или имеет неверный формат.</string> |
|
||||||
<string name="refresh_unavailable">Невозможно обновить изображения.</string> |
|
||||||
<string name="load_image_unavailable">Невозможно загузить полное изображение.</string> |
|
||||||
<string name="last_image">Это последнее изображение.</string> |
|
||||||
<string name="first_image">Это первое изображение.</string> |
|
||||||
<string name="picture">Изображение:</string> |
|
||||||
<string name="size">Размеры:</string> |
|
||||||
<string name="pixels">пикселей.</string> |
|
||||||
<string name="back">назад</string> |
|
||||||
</resources> |
|
@ -1,35 +1,72 @@ |
|||||||
package example.imageviewer |
package example.imageviewer |
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility |
||||||
|
import androidx.compose.animation.fadeIn |
||||||
|
import androidx.compose.animation.fadeOut |
||||||
|
import androidx.compose.animation.slideInHorizontally |
||||||
|
import androidx.compose.animation.slideOutHorizontally |
||||||
import androidx.compose.foundation.layout.fillMaxSize |
import androidx.compose.foundation.layout.fillMaxSize |
||||||
import androidx.compose.material.Surface |
import androidx.compose.material3.Surface |
||||||
import androidx.compose.runtime.Composable |
import androidx.compose.runtime.Composable |
||||||
import androidx.compose.runtime.MutableState |
import androidx.compose.runtime.LaunchedEffect |
||||||
|
import androidx.compose.runtime.remember |
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import example.imageviewer.model.* |
import example.imageviewer.model.GalleryScreenState |
||||||
|
import example.imageviewer.model.ScreenState |
||||||
|
import example.imageviewer.model.bigUrl |
||||||
import example.imageviewer.view.FullscreenImage |
import example.imageviewer.view.FullscreenImage |
||||||
import example.imageviewer.view.MainScreen |
import example.imageviewer.view.MainScreen |
||||||
|
import kotlinx.coroutines.flow.Flow |
||||||
|
import kotlinx.coroutines.flow.emptyFlow |
||||||
|
|
||||||
|
enum class ExternalImageViewerEvent { |
||||||
|
Foward, |
||||||
|
Back |
||||||
|
} |
||||||
|
|
||||||
@Composable |
@Composable |
||||||
internal fun ImageViewerCommon(state: MutableState<State>, dependencies: Dependencies) { |
internal fun ImageViewerCommon( |
||||||
state.refresh(dependencies) |
dependencies: Dependencies, |
||||||
|
externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow() |
||||||
|
) { |
||||||
|
val galleryScreenState = remember { GalleryScreenState() } |
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { |
LaunchedEffect(Unit) { |
||||||
when (state.value.screen) { |
galleryScreenState.refresh(dependencies) |
||||||
ScreenState.Miniatures -> { |
} |
||||||
MainScreen(state, dependencies) |
LaunchedEffect(Unit) { |
||||||
|
externalEvents.collect { |
||||||
|
when (it) { |
||||||
|
ExternalImageViewerEvent.Foward -> galleryScreenState.nextImage() |
||||||
|
ExternalImageViewerEvent.Back -> galleryScreenState.previousImage() |
||||||
} |
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
ScreenState.FullScreen -> { |
Surface(modifier = Modifier.fillMaxSize()) { |
||||||
FullscreenImage( |
AnimatedVisibility( |
||||||
picture = state.value.picture, |
galleryScreenState.screen == ScreenState.Miniatures, |
||||||
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, |
enter = fadeIn(), |
||||||
getFilter = { dependencies.getFilter(it) }, |
exit = fadeOut() |
||||||
localization = dependencies.localization, |
) { |
||||||
back = { state.value = state.value.copy(screen = ScreenState.Miniatures) }, |
MainScreen(galleryScreenState, dependencies) |
||||||
nextImage = { state.nextImage() }, |
} |
||||||
previousImage = { state.previousImage() }, |
|
||||||
) |
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() }, |
||||||
|
) |
||||||
} |
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,77 @@ |
|||||||
|
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 |
||||||
|
} |
@ -1,85 +0,0 @@ |
|||||||
package example.imageviewer.model |
|
||||||
|
|
||||||
import androidx.compose.runtime.MutableState |
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import example.imageviewer.Dependencies |
|
||||||
import io.ktor.client.request.* |
|
||||||
import io.ktor.client.statement.* |
|
||||||
import kotlinx.coroutines.CancellationException |
|
||||||
import kotlinx.coroutines.async |
|
||||||
import kotlinx.coroutines.awaitAll |
|
||||||
import kotlinx.coroutines.launch |
|
||||||
import kotlinx.serialization.builtins.ListSerializer |
|
||||||
|
|
||||||
data class State( |
|
||||||
val currentImageIndex: Int = 0, |
|
||||||
val miniatures: Map<Picture, ImageBitmap> = emptyMap(), |
|
||||||
val pictures: List<Picture> = emptyList(), |
|
||||||
val screen: ScreenState = ScreenState.Miniatures |
|
||||||
) |
|
||||||
|
|
||||||
sealed interface ScreenState { |
|
||||||
object Miniatures : ScreenState |
|
||||||
object FullScreen : ScreenState |
|
||||||
} |
|
||||||
|
|
||||||
val State.isContentReady get() = pictures.isNotEmpty() |
|
||||||
val State.picture get():Picture? = pictures.getOrNull(currentImageIndex) |
|
||||||
|
|
||||||
fun <T> MutableState<T>.modifyState(modification: T.() -> T) { |
|
||||||
value = value.modification() |
|
||||||
} |
|
||||||
|
|
||||||
fun MutableState<State>.nextImage() = modifyState { |
|
||||||
var newIndex = currentImageIndex + 1 |
|
||||||
if (newIndex > pictures.lastIndex) { |
|
||||||
newIndex = 0 |
|
||||||
} |
|
||||||
copy(currentImageIndex = newIndex) |
|
||||||
} |
|
||||||
|
|
||||||
fun MutableState<State>.previousImage() = modifyState { |
|
||||||
var newIndex = currentImageIndex - 1 |
|
||||||
if (newIndex < 0) { |
|
||||||
newIndex = pictures.lastIndex |
|
||||||
} |
|
||||||
copy(currentImageIndex = newIndex) |
|
||||||
} |
|
||||||
|
|
||||||
fun MutableState<State>.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().toMap() |
|
||||||
|
|
||||||
modifyState { |
|
||||||
copy(pictures = pictures, miniatures = 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() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fun MutableState<State>.setSelectedIndex(index: Int) = modifyState { |
|
||||||
copy(currentImageIndex = index) |
|
||||||
} |
|
||||||
|
|
||||||
fun MutableState<State>.toFullscreen(index: Int = value.currentImageIndex) = modifyState { |
|
||||||
copy( |
|
||||||
currentImageIndex = index, |
|
||||||
screen = ScreenState.FullScreen |
|
||||||
) |
|
||||||
} |
|
@ -0,0 +1,5 @@ |
|||||||
|
package example.imageviewer.utils |
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher |
||||||
|
|
||||||
|
expect val ioDispatcher: CoroutineDispatcher |
After Width: | Height: | Size: 5.4 KiB |
@ -0,0 +1,5 @@ |
|||||||
|
package example.imageviewer.utils |
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
|
||||||
|
actual val ioDispatcher = Dispatchers.IO |
@ -0,0 +1,6 @@ |
|||||||
|
package example.imageviewer.utils |
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
|
||||||
|
// https://github.com/Kotlin/kotlinx.coroutines/issues/3205 |
||||||
|
actual val ioDispatcher = Dispatchers.Default |
@ -1,8 +1,7 @@ |
|||||||
package example.imageviewer.view |
package example.imageviewer.view |
||||||
|
|
||||||
import androidx.compose.runtime.MutableState |
|
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import example.imageviewer.model.ScalableState |
import example.imageviewer.model.ScalableState |
||||||
|
|
||||||
actual fun Modifier.addUserInput(state: MutableState<ScalableState>): Modifier = |
actual fun Modifier.addUserInput(state: ScalableState): Modifier = |
||||||
addTouchUserInput(state) |
addTouchUserInput(state) |
||||||
|
Binary file not shown.
Loading…
Reference in new issue