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 |
||||
|
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.ui.window.application |
||||
import example.imageviewer.view.ImageViewerDesktop |
||||
|
||||
fun main() = application { |
||||
MaterialTheme { |
||||
ImageViewerDesktop() |
||||
} |
||||
ImageViewerDesktop() |
||||
} |
||||
|
Binary file not shown.
@ -1,5 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME |
||||
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 |
||||
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 |
||||
|
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.ui.Modifier |
||||
import example.imageviewer.model.ScalableState |
||||
|
||||
actual fun Modifier.addUserInput(state: MutableState<ScalableState>) = |
||||
actual fun Modifier.addUserInput(state: ScalableState) = |
||||
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 |
||||
|
||||
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.material.Surface |
||||
import androidx.compose.material3.Surface |
||||
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 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.MainScreen |
||||
import kotlinx.coroutines.flow.Flow |
||||
import kotlinx.coroutines.flow.emptyFlow |
||||
|
||||
enum class ExternalImageViewerEvent { |
||||
Foward, |
||||
Back |
||||
} |
||||
|
||||
@Composable |
||||
internal fun ImageViewerCommon(state: MutableState<State>, dependencies: Dependencies) { |
||||
state.refresh(dependencies) |
||||
internal fun ImageViewerCommon( |
||||
dependencies: Dependencies, |
||||
externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow() |
||||
) { |
||||
val galleryScreenState = remember { GalleryScreenState() } |
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) { |
||||
when (state.value.screen) { |
||||
ScreenState.Miniatures -> { |
||||
MainScreen(state, dependencies) |
||||
LaunchedEffect(Unit) { |
||||
galleryScreenState.refresh(dependencies) |
||||
} |
||||
LaunchedEffect(Unit) { |
||||
externalEvents.collect { |
||||
when (it) { |
||||
ExternalImageViewerEvent.Foward -> galleryScreenState.nextImage() |
||||
ExternalImageViewerEvent.Back -> galleryScreenState.previousImage() |
||||
} |
||||
} |
||||
} |
||||
|
||||
ScreenState.FullScreen -> { |
||||
FullscreenImage( |
||||
picture = state.value.picture, |
||||
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, |
||||
getFilter = { dependencies.getFilter(it) }, |
||||
localization = dependencies.localization, |
||||
back = { state.value = state.value.copy(screen = ScreenState.Miniatures) }, |
||||
nextImage = { state.nextImage() }, |
||||
previousImage = { state.previousImage() }, |
||||
) |
||||
} |
||||
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() }, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
@ -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 |
||||
|
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.ui.Modifier |
||||
import example.imageviewer.model.ScalableState |
||||
|
||||
actual fun Modifier.addUserInput(state: MutableState<ScalableState>): Modifier = |
||||
actual fun Modifier.addUserInput(state: ScalableState): Modifier = |
||||
addTouchUserInput(state) |
||||
|
Binary file not shown.
Loading…
Reference in new issue