diff --git a/experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt b/experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt index 96fe7556df..36fdf3914f 100644 --- a/experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt +++ b/experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt @@ -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() } diff --git a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar index 249e5832f0..943f0cbfa7 100644 Binary files a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar and b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar differ diff --git a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties index ae04661ee7..f398c33c4b 100755 --- a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties +++ b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/experimental/examples/imageviewer/gradlew b/experimental/examples/imageviewer/gradlew index a69d9cb6c2..65dcd68d65 100755 --- a/experimental/examples/imageviewer/gradlew +++ b/experimental/examples/imageviewer/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/experimental/examples/imageviewer/gradlew.bat b/experimental/examples/imageviewer/gradlew.bat index f127cfd49d..93e3f59f13 100755 --- a/experimental/examples/imageviewer/gradlew.bat +++ b/experimental/examples/imageviewer/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj b/experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj index 2d52838b5e..db611b6470 100644 --- a/experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ @@ -340,7 +340,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}"; - PRODUCT_NAME = "Imageviewer"; + PRODUCT_NAME = Imageviewer; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -364,7 +364,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}"; - PRODUCT_NAME = "Imageviewer"; + PRODUCT_NAME = Imageviewer; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/experimental/examples/imageviewer/shared/build.gradle.kts b/experimental/examples/imageviewer/shared/build.gradle.kts index 9d7a377757..d3960e4aa8 100755 --- a/experimental/examples/imageviewer/shared/build.gradle.kts +++ b/experimental/examples/imageviewer/shared/build.gradle.kts @@ -36,6 +36,8 @@ kotlin { implementation(compose.material) implementation("org.jetbrains.compose.components:components-resources:1.3.0-beta04-dev879") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.material3) } } val androidMain by getting { diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt new file mode 100644 index 0000000000..100d513e91 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt @@ -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 \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt index 366e5427ef..9bfadaaf5d 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt @@ -3,16 +3,18 @@ package example.imageviewer.view import android.content.Context import android.widget.Toast import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext -import example.imageviewer.* +import example.imageviewer.Dependencies +import example.imageviewer.ImageViewerCommon +import example.imageviewer.Localization +import example.imageviewer.Notification +import example.imageviewer.PopupNotification import example.imageviewer.core.BitmapFilter import example.imageviewer.core.FilterType import example.imageviewer.model.ContentRepository -import example.imageviewer.model.State import example.imageviewer.model.adapter import example.imageviewer.model.createNetworkRepository import example.imageviewer.model.filtration.BlurFilter @@ -20,21 +22,20 @@ import example.imageviewer.model.filtration.GrayScaleFilter import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.shared.R import example.imageviewer.style.ImageViewerTheme -import io.ktor.client.* -import io.ktor.client.engine.okhttp.* +import example.imageviewer.toImageBitmap +import example.imageviewer.utils.ioDispatcher +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch @Composable fun ImageViewerAndroid() { val context: Context = LocalContext.current - val ioScope = rememberCoroutineScope { Dispatchers.IO } + val ioScope = rememberCoroutineScope { ioDispatcher } val dependencies = remember(context, ioScope) { getDependencies(context, ioScope) } - val state = remember { mutableStateOf(State()) } ImageViewerTheme { - ImageViewerCommon(state, dependencies) + ImageViewerCommon(dependencies) } } @@ -70,9 +71,7 @@ private fun getDependencies(context: Context, ioScope: CoroutineScope) = object override val notification: Notification = object : PopupNotification(localization) { override fun showPopUpMessage(text: String) { - GlobalScope.launch(Dispatchers.Main) { - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() - } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() } } } diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt index fa88d1804b..775b837077 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt @@ -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) = +actual fun Modifier.addUserInput(state: ScalableState) = addTouchUserInput(state) diff --git a/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml b/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml new file mode 100755 index 0000000000..134f6a332a --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml @@ -0,0 +1,17 @@ + + + ImageViewer + Bilder werden geladen... + Bildverzeichnis ist leer. + Kein Internetzugriff. + Bildverzeichnis beschädigt oder leer. + Kann Bilder nicht aktualisieren. + Kann volles Bild nicht laden. + Dies ist das letzte Bild. + Dies ist das erste Bild. + Bild: + Abmessungen: + Pixel. + Zurück + Aktualisieren + diff --git a/experimental/examples/imageviewer/shared/src/androidMain/res/values-ru/strings.xml b/experimental/examples/imageviewer/shared/src/androidMain/res/values-ru/strings.xml deleted file mode 100755 index adb38dc804..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/res/values-ru/strings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - ImageViewer - Загружаем изображения... - Репозиторий пуст. - Нет доступа в интернет. - Список изображений в репозитории пуст или имеет неверный формат. - Невозможно обновить изображения. - Невозможно загузить полное изображение. - Это последнее изображение. - Это первое изображение. - Изображение: - Размеры: - пикселей. - назад - \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml b/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml index cbde2d5a37..2f055b36cb 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml +++ b/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml @@ -1,5 +1,5 @@ - ImageViewer + My Memories Loading images... Repository is empty. No internet access. @@ -12,4 +12,5 @@ Size: pixels. back + Refresh \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt index 292836ead9..07c45ce256 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt @@ -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, dependencies: Dependencies) { - state.refresh(dependencies) +internal fun ImageViewerCommon( + dependencies: Dependencies, + externalEvents: Flow = 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() }, + ) } } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt new file mode 100644 index 0000000000..aba589c4c6 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt @@ -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() + var screen by mutableStateOf(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 +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt index 7463f87381..134a5acb70 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt @@ -6,6 +6,25 @@ import kotlinx.serialization.Serializable data class Picture(val big: String, val small: String) fun getNameURL(url: String): String = url.substring(url.lastIndexOf('/') + 1, url.length) -val Picture.name get() = getNameURL(big) +val Picture.name: String get() { + val realName = getNameURL(big) + return mockNames.getOrElse(realName) { realName } +} val Picture.bigUrl get() = "$BASE_URL/$big" val Picture.smallUrl get() = "$BASE_URL/$small" + +val mockNames = mapOf( + "1.jpg" to "Gondolas", + "2.jpg" to "Winter Pier", + "3.jpg" to "Kitties outside", + "4.jpg" to "Heap of trees", + "5.jpg" to "Resilient Cacti", + "6.jpg" to "Swirls", + "7.jpg" to "Gradient Descent", + "8.jpg" to "Sleepy in Seattle", + "9.jpg" to "Lightful infrastructure", + "10.jpg" to "Compose Pathway", + "11.jpg" to "Rotary", + "12.jpg" to "Towering", + "13.jpg" to "Vasa" +) \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt index 6c9bf68752..96584a20c8 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt @@ -1,17 +1,18 @@ package example.imageviewer.model -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize -data class ScalableState( - val imageSize: IntSize, - val boxSize: IntSize = IntSize(1, 1), - val offset: IntOffset = IntOffset.Zero, - val scale: Float = 1f -) +class ScalableState(val imageSize: IntSize) { + var boxSize by mutableStateOf(IntSize(1, 1)) + var offset by mutableStateOf(IntOffset.Zero) + var scale by mutableStateOf(1f) +} val ScalableState.visiblePart get() : IntRect { @@ -32,46 +33,46 @@ val ScalableState.visiblePart return IntRect(offset = offset, size = size) } -fun MutableState.changeBoxSize(size: IntSize) = modifyState { - copy(boxSize = size) - .updateOffsetLimits() +fun ScalableState.changeBoxSize(size: IntSize) { + boxSize = size + updateOffsetLimits() } -fun MutableState.setScale(scale: Float) = modifyState { - copy(scale = scale) - .updateOffsetLimits() +fun ScalableState.setScale(scale: Float) { + this.scale = scale } -fun MutableState.addScale(diff: Float) = modifyState { - if (scale + diff > MAX_SCALE) { - copy(scale = MAX_SCALE) +fun ScalableState.addScale(diff: Float) { + scale = if (scale + diff > MAX_SCALE) { + MAX_SCALE } else if (scale + diff < MIN_SCALE) { - copy(scale = MIN_SCALE) + MIN_SCALE } else { - copy(scale = scale + diff) - }.updateOffsetLimits() + scale + diff + } + updateOffsetLimits() } -fun MutableState.addDragAmount(diff: Offset) = modifyState { - copy(offset = offset - IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt())) - .updateOffsetLimits() +fun ScalableState.addDragAmount(diff: Offset) { + offset -= IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt()) + updateOffsetLimits() } -private fun ScalableState.updateOffsetLimits(): ScalableState { - var result = this +private fun ScalableState.updateOffsetLimits() { if (offset.x + visiblePart.width > imageSize.width) { - result = result.changeOffset(x = imageSize.width - visiblePart.width) + changeOffset(x = imageSize.width - visiblePart.width) } if (offset.y + visiblePart.height > imageSize.height) { - result = result.changeOffset(y = imageSize.height - visiblePart.height) + changeOffset(y = imageSize.height - visiblePart.height) } if (offset.x < 0) { - result = result.changeOffset(x = 0) + changeOffset(x = 0) } if (offset.y < 0) { - result = result.changeOffset(y = 0) + changeOffset(y = 0) } - return result } -private fun ScalableState.changeOffset(x: Int = offset.x, y: Int = offset.y) = copy(offset = IntOffset(x, y)) +private fun ScalableState.changeOffset(x: Int = offset.x, y: Int = offset.y) { + offset = IntOffset(x, y) +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/State.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/State.kt deleted file mode 100644 index 593b830976..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/State.kt +++ /dev/null @@ -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 = emptyMap(), - val pictures: List = 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 MutableState.modifyState(modification: T.() -> T) { - value = value.modification() -} - -fun MutableState.nextImage() = modifyState { - var newIndex = currentImageIndex + 1 - if (newIndex > pictures.lastIndex) { - newIndex = 0 - } - copy(currentImageIndex = newIndex) -} - -fun MutableState.previousImage() = modifyState { - var newIndex = currentImageIndex - 1 - if (newIndex < 0) { - newIndex = pictures.lastIndex - } - copy(currentImageIndex = newIndex) -} - -fun MutableState.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.setSelectedIndex(index: Int) = modifyState { - copy(currentImageIndex = index) -} - -fun MutableState.toFullscreen(index: Int = value.currentImageIndex) = modifyState { - copy( - currentImageIndex = index, - screen = ScreenState.FullScreen - ) -} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt index b63fafac57..6e8e568402 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt @@ -1,8 +1,9 @@ package example.imageviewer.style import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color object ImageviewerColors { @@ -18,6 +19,18 @@ object ImageviewerColors { val TranslucentWhite = Color(255, 255, 255, 20) val Transparent = Color.Transparent + val KotlinGradient0 = Color(0xFF7F52FF) + val KotlinGradient50 = Color(0xFFC811E2) + val KotlinGradient100 = Color(0xFFE54857) + + val kotlinHorizontalGradientBrush = Brush.horizontalGradient( + colors = listOf( + KotlinGradient0, + KotlinGradient50, + KotlinGradient100 + ) + ) + fun buttonBackground(isHover: Boolean) = if (isHover) TranslucentBlack else Transparent } @@ -25,15 +38,9 @@ object ImageviewerColors { internal fun ImageViewerTheme(content: @Composable () -> Unit) { isSystemInDarkTheme() // todo check and change colors MaterialTheme( - colors = MaterialTheme.colors.copy( - primary = ImageviewerColors.Foreground, - secondary = ImageviewerColors.LightGray, - background = ImageviewerColors.DarkGray, - surface = ImageviewerColors.Gray, - onPrimary = ImageviewerColors.Foreground, - onSecondary = Color.Black, - onBackground = ImageviewerColors.Foreground, - onSurface = ImageviewerColors.Foreground + colorScheme = MaterialTheme.colorScheme.copy( + background = Color(0xFF1B1B1B), + onBackground = Color(0xFFFFFFFF) ) ) { content() diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt new file mode 100644 index 0000000000..1e31a01e01 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt @@ -0,0 +1,5 @@ +package example.imageviewer.utils + +import kotlinx.coroutines.CoroutineDispatcher + +expect val ioDispatcher: CoroutineDispatcher \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt index 4675c84a21..0f8efcb53d 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt @@ -5,15 +5,14 @@ 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.* 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.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.input.key.* @@ -41,7 +40,8 @@ internal fun FullscreenImage( nextImage: () -> Unit, previousImage: () -> Unit, ) { - val filtersState = remember { mutableStateOf(emptySet()) } + val availableFilters = FilterType.values().toList() + var selectedFilters by remember { mutableStateOf(emptySet()) } val originalImageState = remember(picture) { mutableStateOf(null) } LaunchedEffect(picture) { @@ -51,11 +51,10 @@ internal fun FullscreenImage( } val originalImage = originalImageState.value - val filters = filtersState.value - val imageWithFilter = remember(originalImage, filters) { + val imageWithFilter = remember(originalImage, selectedFilters) { if (originalImage != null) { var result: ImageBitmap = originalImage - for (filter in filters.map { getFilter(it) }) { + for (filter in selectedFilters.map { getFilter(it) }) { result = filter.apply(result) } result @@ -63,17 +62,28 @@ internal fun FullscreenImage( null } } - - Box(Modifier.fillMaxSize().background(color = MaterialTheme.colors.background)) { + Box(Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background)) { Column { - Toolbar(picture?.name ?: "", filtersState, localization, back) + FullscreenImageBar( + localization, + picture?.name, + back, + availableFilters, + selectedFilters, + onSelectFilter = { + if (it !in selectedFilters) { + selectedFilters += it + } else { + selectedFilters -= it + } + }) if (imageWithFilter != null) { val imageSize = IntSize(imageWithFilter.width, imageWithFilter.height) - val scalableState = remember(imageSize) { mutableStateOf(ScalableState(imageSize)) } - val visiblePartOfImage: IntRect = scalableState.value.visiblePart + val scalableState = remember(imageSize) { ScalableState(imageSize) } + val visiblePartOfImage: IntRect = scalableState.visiblePart Slider( modifier = Modifier.fillMaxWidth(), - value = scalableState.value.scale, + value = scalableState.scale, valueRange = MIN_SCALE..MAX_SCALE, onValueChange = { scalableState.setScale(it) }, ) @@ -99,84 +109,75 @@ internal fun FullscreenImage( } } - FloatingActionButton(modifier = Modifier.align(Alignment.BottomStart).padding(10.dp), onClick = previousImage) { + FloatingActionButton( + modifier = Modifier.align(Alignment.BottomStart).padding(10.dp), + containerColor = ImageviewerColors.KotlinGradient0, + onClick = previousImage + ) { Icon( imageVector = Icons.Filled.KeyboardArrowLeft, contentDescription = "Previous", - tint = MaterialTheme.colors.primary + tint = MaterialTheme.colorScheme.onBackground ) } - FloatingActionButton(modifier = Modifier.align(Alignment.BottomEnd).padding(10.dp), onClick = nextImage) { + FloatingActionButton( + modifier = Modifier.align(Alignment.BottomEnd).padding(10.dp), + containerColor = ImageviewerColors.KotlinGradient0, + onClick = nextImage + ) { Icon( imageVector = Icons.Filled.KeyboardArrowRight, contentDescription = "Next", - tint = MaterialTheme.colors.primary + tint = MaterialTheme.colorScheme.onBackground ) } } } -@OptIn(ExperimentalResourceApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalResourceApi::class) @Composable -private fun Toolbar( - title: String, - filtersState: MutableState>, +private fun FullscreenImageBar( localization: Localization, - back: () -> Unit + pictureName: String?, + onBack: () -> Unit, + filters: List, + selectedFilters: Set, + onSelectFilter: (FilterType) -> Unit ) { - val backButtonInteractionSource = remember { MutableInteractionSource() } - val backButtonHover by backButtonInteractionSource.collectIsHoveredAsState() - Surface( - modifier = Modifier.height(44.dp) - ) { - Row(modifier = Modifier.padding(end = 30.dp)) { - Surface( - color = Color.Transparent, - modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically), - shape = CircleShape - ) { - Tooltip(localization.back) { - Image( - resource("back.png").rememberImageBitmap().orEmpty(), - contentDescription = null, - modifier = Modifier.size(38.dp) - .hoverable(backButtonInteractionSource) - .background(color = ImageviewerColors.buttonBackground(backButtonHover)) - .clickable { back() } - ) - } + TopAppBar( + modifier = Modifier.background(brush = ImageviewerColors.kotlinHorizontalGradientBrush), + colors = TopAppBarDefaults.smallTopAppBarColors( + containerColor = ImageviewerColors.Transparent, + titleContentColor = MaterialTheme.colorScheme.onBackground + ), + title = { + Text("${localization.picture} ${pictureName ?: "Unknown"}") + }, + navigationIcon = { + Tooltip(localization.back) { + Image( + resource("back.png").rememberImageBitmap().orEmpty(), + contentDescription = null, + modifier = Modifier.size(38.dp) + .clip(CircleShape) + .clickable { onBack() } + ) } - Text( - title, - maxLines = 1, - modifier = Modifier.padding(start = 30.dp).weight(1f) - .align(Alignment.CenterVertically), - style = MaterialTheme.typography.body1 - ) - - Surface( - color = Color(255, 255, 255, 40), - modifier = Modifier.size(154.dp, 38.dp) - .align(Alignment.CenterVertically), - shape = CircleShape - ) { - Row(Modifier.horizontalScroll(rememberScrollState())) { - for (type in FilterType.values()) { - FilterButton(filtersState.value.contains(type), type, onClick = { - filtersState.value = if (filtersState.value.contains(type)) { - filtersState.value - type - } else { - filtersState.value + type - } - }) - } - } + }, + actions = { + for (type in filters) { + FilterButton(active = type in selectedFilters, + type, + onClick = { + onSelectFilter(type) + }) } } - } + ) } + @Composable private fun FilterButton( active: Boolean, diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt index f97df4b357..b8af7c22e9 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt @@ -3,10 +3,7 @@ package example.imageviewer.view import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -15,10 +12,10 @@ import androidx.compose.ui.unit.dp @Composable internal fun LoadingScreen(text: String = "") { Box( - modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colors.background) + modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background) ) { Box(modifier = Modifier.align(Alignment.Center)) { - Surface(elevation = 4.dp, shape = CircleShape) { + Surface(/*elevation = 4.dp, */shape = CircleShape) { CircularProgressIndicator( modifier = Modifier.size(50.dp).padding(3.dp, 3.dp, 4.dp, 4.dp) ) @@ -27,7 +24,7 @@ internal fun LoadingScreen(text: String = "") { Text( text = text, modifier = Modifier.align(Alignment.Center).offset(0.dp, 70.dp), - style = MaterialTheme.typography.body1 + style = MaterialTheme.typography.bodyMedium ) } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt index 97763df099..86deae0d47 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt @@ -1,72 +1,229 @@ package example.imageviewer.view -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +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.Dependencies -import example.imageviewer.model.* -import example.imageviewer.model.State -import example.imageviewer.style.* +import example.imageviewer.model.GalleryScreenState +import example.imageviewer.model.Picture +import example.imageviewer.model.PictureWithThumbnail +import example.imageviewer.model.bigUrl +import example.imageviewer.style.ImageviewerColors +import example.imageviewer.style.ImageviewerColors.kotlinHorizontalGradientBrush import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.orEmpty import org.jetbrains.compose.resources.rememberImageBitmap import org.jetbrains.compose.resources.resource @Composable -internal fun MainScreen(state: MutableState, dependencies: Dependencies) { - Column { - TopContent(state, dependencies) - ScrollableColumn( - modifier = Modifier.fillMaxSize() - ) { - for (i in state.value.pictures.indices) { - val picture = state.value.pictures[i] - Miniature( - picture = picture, - image = state.value.miniatures[picture], - onClickSelect = { - state.setSelectedIndex(i) - }, - onClickFullScreen = { - state.toFullscreen(i) - }, - onClickInfo = { - dependencies.notification.notifyImageData(picture) - }, - ) - Spacer(modifier = Modifier.height(4.dp)) - } +internal fun GalleryHeader() { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(10.dp).fillMaxWidth() + ) { + Text( + "My Gallery", + fontSize = 25.sp, + color = MaterialTheme.colorScheme.onBackground, + fontStyle = FontStyle.Italic + ) + } +} + +enum class GalleryStyle { + SQUARES, + 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) } + Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { + TitleBar( + onRefresh = { galleryScreenState.refresh(dependencies) }, + onToggle = { galleryStyle = galleryStyle.toggled() }, + dependencies + ) + if (needShowPreview()) { + PreviewImage( + getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, + picture = galleryScreenState.picture, onClick = { + galleryScreenState.toFullscreen() + }) + } + when (galleryStyle) { + GalleryStyle.SQUARES -> SquaresGalleryView( + galleryScreenState.picturesWithThumbnail, + galleryScreenState.picturesWithThumbnail.getOrNull(galleryScreenState.currentPictureIndex), + onSelect = { galleryScreenState.selectPicture(it) } + ) + + GalleryStyle.LIST -> ListGalleryView( + galleryScreenState.picturesWithThumbnail, + dependencies, + onSelect = { galleryScreenState.selectPicture(it) }, + onFullScreen = { galleryScreenState.toFullscreen(it) } + ) } } - if (!state.value.isContentReady) { + if (!galleryScreenState.isContentReady) { LoadingScreen(dependencies.localization.loading) } } @Composable -private fun TopContent(state: MutableState, dependencies: Dependencies) { - TitleBar(state, dependencies) - if (needShowPreview()) { - PreviewImage(state = state, getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }) +private fun SquaresGalleryView( + images: List, + selectedImage: PictureWithThumbnail?, + onSelect: (Picture) -> Unit +) { + LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) { + item { + MakeNewMemoryMiniature() + } + itemsIndexed(images) { idx, image -> + val isSelected = image == selectedImage + val (picture, bitmap) = image + SquareMiniature(bitmap, onClick = { onSelect(picture) }, isHighlighted = isSelected) + } } } -@OptIn(ExperimentalResourceApi::class) @Composable -private fun TitleBar(state: MutableState, dependencies: Dependencies) { +private fun MakeNewMemoryMiniature() { + Box( + Modifier.aspectRatio(1.0f) + .clickable { + // TODO: Open Camera! + }, contentAlignment = Alignment.Center + ) { + Text( + "+", + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + fontSize = 50.sp + ) + } +} + +@Composable +private fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick: () -> Unit) { + Image( + bitmap = image, + contentDescription = null, + modifier = Modifier.aspectRatio(1.0f).clickable { onClick() }.then( + if (isHighlighted) { + Modifier.border(BorderStroke(5.dp, Color.White)) + } else Modifier + ), + contentScale = ContentScale.Crop + ) +} + +@Composable +private fun ListGalleryView( + pictures: List, + dependencies: Dependencies, + onSelect: (Picture) -> Unit, + onFullScreen: (Int) -> Unit +) { + GalleryHeader() + Spacer(modifier = Modifier.height(10.dp)) + ScrollableColumn( + modifier = Modifier.fillMaxSize() + ) { + for ((idx, picWithThumb) in pictures.withIndex()) { + val (picture, miniature) = picWithThumb + Miniature( + picture = picture, + image = miniature, + onClickSelect = { + onSelect(picture) + }, + onClickFullScreen = { + onFullScreen(idx) + }, + onClickInfo = { + dependencies.notification.notifyImageData(picture) + }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + } +} + +@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun TitleBar(onRefresh: () -> Unit, onToggle: () -> Unit, dependencies: Dependencies) { TopAppBar( - backgroundColor = MaterialTheme.colors.surface, + modifier = Modifier.background(brush = kotlinHorizontalGradientBrush), + colors = TopAppBarDefaults.smallTopAppBarColors( + containerColor = ImageviewerColors.Transparent, + titleContentColor = MaterialTheme.colorScheme.onBackground + ), title = { Row(Modifier.height(50.dp)) { Text( dependencies.localization.appName, - modifier = Modifier.weight(1f).align(Alignment.CenterVertically) + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + fontWeight = FontWeight.Bold ) + Surface( + color = ImageviewerColors.Transparent, + modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically), + shape = CircleShape + ) { + Image( + bitmap = resource("list_view.png").rememberImageBitmap().orEmpty(), + contentDescription = null, + modifier = Modifier.size(35.dp).clickable { + onToggle() + } + ) + } Surface( color = ImageviewerColors.Transparent, modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically), @@ -76,7 +233,7 @@ private fun TitleBar(state: MutableState, dependencies: Dependencies) { bitmap = resource("refresh.png").rememberImageBitmap().orEmpty(), contentDescription = null, modifier = Modifier.size(35.dp).clickable { - state.refresh(dependencies) + onRefresh() } ) } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt index 1e725cd8b0..a756581e48 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt @@ -1,24 +1,38 @@ package example.imageviewer.view +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable 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.RectangleShape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import example.imageviewer.model.* -import example.imageviewer.style.* +import example.imageviewer.model.Picture +import example.imageviewer.model.name import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.orEmpty import org.jetbrains.compose.resources.rememberImageBitmap import org.jetbrains.compose.resources.resource -@OptIn(ExperimentalResourceApi::class) +@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class) @Composable internal fun Miniature( picture: Picture, @@ -28,24 +42,28 @@ internal fun Miniature( onClickInfo: () -> Unit, ) { Card( - backgroundColor = ImageviewerColors.MiniatureColor, modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp) - .fillMaxWidth() - .clickable { - onClickSelect() - }, - shape = RectangleShape, - elevation = 2.dp + .fillMaxWidth(), + onClick = { onClickSelect() }, + shape = RoundedCornerShape(200.dp), + border = BorderStroke(1.dp, Color.White), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground + ) + ) { Row(modifier = Modifier.padding(end = 30.dp)) { val modifier = Modifier.height(70.dp) - .width(90.dp) - .padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp) + .width(70.dp) if (image != null) { Image( image, contentDescription = null, - modifier = modifier.clickable { onClickFullScreen() }, + modifier = modifier + .clip(CircleShape) + .border(BorderStroke(1.dp, Color.White), CircleShape) + .clickable { onClickFullScreen() }, contentScale = ContentScale.Crop ) } else { @@ -53,8 +71,9 @@ internal fun Miniature( } Text( text = picture.name, - modifier = Modifier.weight(1f).align(Alignment.CenterVertically).padding(start = 16.dp), - style = MaterialTheme.typography.body1 + modifier = Modifier.weight(1f).align(Alignment.CenterVertically) + .padding(start = 16.dp), + style = MaterialTheme.typography.titleLarge ) Image( diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt index 85ebb73cc0..02c1453619 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt @@ -1,51 +1,81 @@ package example.imageviewer.view +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.with import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import example.imageviewer.model.* -import example.imageviewer.model.State -import example.imageviewer.style.* +import example.imageviewer.model.Picture +import example.imageviewer.style.ImageviewerColors.kotlinHorizontalGradientBrush import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.orEmpty -import org.jetbrains.compose.resources.rememberImageBitmap -import org.jetbrains.compose.resources.resource -@OptIn(ExperimentalResourceApi::class) +@OptIn(ExperimentalResourceApi::class, ExperimentalAnimationApi::class) @Composable -internal fun PreviewImage(state: MutableState, getImage: suspend (Picture) -> ImageBitmap) { - val pictures = state.value.pictures - val index = state.value.currentImageIndex - val imageState = remember(pictures, index) { mutableStateOf(null) } - LaunchedEffect(pictures, index) { - val picture = pictures.getOrNull(index) +internal fun PreviewImage( + picture: Picture?, + onClick: () -> Unit, + getImage: suspend (Picture) -> ImageBitmap +) { + var image by remember(picture) { mutableStateOf(null) } + LaunchedEffect(picture) { if (picture != null) { - imageState.value = getImage(picture) + image = getImage(picture) } } - val image = imageState.value + Spacer( + modifier = Modifier.height(5.dp).fillMaxWidth() + .background(brush = kotlinHorizontalGradientBrush) + ) Card( - backgroundColor = MaterialTheme.colors.background, modifier = Modifier.height(200.dp) - .clickable { state.toFullscreen() }, - shape = RectangleShape, - elevation = 1.dp + .background(brush = kotlinHorizontalGradientBrush) + .padding(10.dp) + .clickable { onClick() }, + shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp), ) { - Image( - bitmap = image ?: resource("empty.png").rememberImageBitmap().orEmpty(), - contentDescription = null, - modifier = Modifier - .fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp), - contentScale = ContentScale.Fit - ) + AnimatedContent( + targetState = image, + transitionSpec = { + slideInVertically(initialOffsetY = { it }) with slideOutVertically(targetOffsetY = { -it }) + } + ) { imageBitmap -> + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + , + contentScale = ContentScale.Crop + ) + } else { + Spacer( + modifier = Modifier.fillMaxSize() + .background(brush = kotlinHorizontalGradientBrush) + ) + } + } } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt index c21a4d0339..88e2844a03 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt @@ -2,7 +2,6 @@ package example.imageviewer.view import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import example.imageviewer.model.ScalableState @@ -10,9 +9,9 @@ import example.imageviewer.model.addDragAmount import example.imageviewer.model.addScale import example.imageviewer.model.setScale -expect fun Modifier.addUserInput(state: MutableState): Modifier +expect fun Modifier.addUserInput(state: ScalableState): Modifier -fun Modifier.addTouchUserInput(state: MutableState): Modifier = +fun Modifier.addTouchUserInput(state: ScalableState): Modifier = pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> state.addDragAmount(pan) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt index 6db9603f25..6a0ca3e85f 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/list_view.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/list_view.png new file mode 100644 index 0000000000..077ee0d0d6 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/list_view.png differ diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt index ef361ecc84..46eb7b076c 100755 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt @@ -18,23 +18,23 @@ object ResString { val refresh: String init { - if (System.getProperty("user.language").equals("ru")) { - appName = "ImageViewer" - loading = "Загружаем изображения..." - repoEmpty = "Репозиторий пуст." - noInternet = "Нет доступа в интернет." - repoInvalid = "Список изображений в репозитории пуст или имеет неверный формат." - refreshUnavailable = "Невозможно обновить изображения." - loadImageUnavailable = "Невозможно загузить полное изображение." - lastImage = "Это последнее изображение." - firstImage = "Это первое изображение." - picture = "Изображение:" - size = "Размеры:" - pixels = "пикселей." - back = "Назад" - refresh = "Обновить" + if (System.getProperty("user.language").equals("de")) { + appName = "Meine Erinnerungen" + loading = "Bilder werden geladen..." + repoEmpty = "Bildverzeichnis ist leer." + noInternet = "Kein Internetzugriff." + repoInvalid = "Bildverzeichnis beschädigt oder leer." + refreshUnavailable = "Kann Bilder nicht aktualisieren." + loadImageUnavailable = "Kann volles Bild nicht laden." + lastImage = "Dies ist das letzte Bild." + firstImage = "Dies ist das erste Bild." + picture = "Bild:" + size = "Abmessungen:" + pixels = "Pixel." + back = "Zurück" + refresh = "Aktualisieren" } else { - appName = "ImageViewer" + appName = "My Memories" loading = "Loading images..." repoEmpty = "Repository is empty." noInternet = "No internet access." diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt new file mode 100644 index 0000000000..2415ebee55 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt @@ -0,0 +1,5 @@ +package example.imageviewer.utils + +import kotlinx.coroutines.Dispatchers + +actual val ioDispatcher = Dispatchers.IO \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt index 9a4a6380ea..e1a98973bf 100755 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt @@ -18,26 +18,40 @@ import example.imageviewer.Notification import example.imageviewer.core.BitmapFilter import example.imageviewer.core.FilterType import example.imageviewer.model.* -import example.imageviewer.model.State import example.imageviewer.model.filtration.BlurFilter import example.imageviewer.model.filtration.GrayScaleFilter import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.style.ImageViewerTheme import example.imageviewer.utils.decorateWithDiskCache import example.imageviewer.utils.getPreferredWindowSize +import example.imageviewer.utils.ioDispatcher import io.ktor.client.* import io.ktor.client.engine.cio.* import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.io.File +class ExternalNavigationEventBus { + private val _events = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST + ) + val events = _events.asSharedFlow() + + fun produceEvent(event: ExternalImageViewerEvent) { + _events.tryEmit(event) + } +} + @OptIn(ExperimentalComposeUiApi::class) @Composable fun ApplicationScope.ImageViewerDesktop() { val toastState = remember { mutableStateOf(ToastState.Hidden) } - val state = remember { mutableStateOf(State()) } - val ioScope: CoroutineScope = rememberCoroutineScope { Dispatchers.IO } + val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher } val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } + val externalNavigationEventBus = remember { ExternalNavigationEventBus() } Window( onCloseRequest = ::exitApplication, @@ -47,11 +61,17 @@ fun ApplicationScope.ImageViewerDesktop() { size = getPreferredWindowSize(800, 1000) ), icon = painterResource("ic_imageviewer_round.png"), + // https://github.com/JetBrains/compose-jb/issues/2741 onKeyEvent = { if (it.type == KeyEventType.KeyUp) { when (it.key) { - Key.DirectionLeft -> state.previousImage() - Key.DirectionRight -> state.nextImage() + Key.DirectionLeft -> externalNavigationEventBus.produceEvent( + ExternalImageViewerEvent.Back + ) + + Key.DirectionRight -> externalNavigationEventBus.produceEvent( + ExternalImageViewerEvent.Foward + ) } } false @@ -62,8 +82,8 @@ fun ApplicationScope.ImageViewerDesktop() { modifier = Modifier.fillMaxSize() ) { ImageViewerCommon( - state = state, - dependencies = dependencies + dependencies = dependencies, + externalEvents = externalNavigationEventBus.events ) Toast(toastState) } @@ -71,50 +91,51 @@ fun ApplicationScope.ImageViewerDesktop() { } } -private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = object : Dependencies { - override val ioScope: CoroutineScope = ioScope - override fun getFilter(type: FilterType): BitmapFilter = when (type) { - FilterType.GrayScale -> GrayScaleFilter() - FilterType.Pixel -> PixelFilter() - FilterType.Blur -> BlurFilter() - } +private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = + object : Dependencies { + override val ioScope: CoroutineScope = ioScope + override fun getFilter(type: FilterType): BitmapFilter = when (type) { + FilterType.GrayScale -> GrayScaleFilter() + FilterType.Pixel -> PixelFilter() + FilterType.Blur -> BlurFilter() + } - override val localization: Localization = object : Localization { - override val back: String get() = ResString.back - override val appName: String get() = ResString.appName - override val loading: String get() = ResString.loading - override val repoInvalid: String get() = ResString.repoInvalid - override val repoEmpty: String get() = ResString.repoEmpty - override val noInternet: String get() = ResString.noInternet - override val loadImageUnavailable: String get() = ResString.loadImageUnavailable - override val lastImage: String get() = ResString.lastImage - override val firstImage: String get() = ResString.firstImage - override val picture: String get() = ResString.picture - override val size: String get() = ResString.size - override val pixels: String get() = ResString.pixels - override val refreshUnavailable: String get() = ResString.refreshUnavailable - } + override val localization: Localization = object : Localization { + override val back: String get() = ResString.back + override val appName: String get() = ResString.appName + override val loading: String get() = ResString.loading + override val repoInvalid: String get() = ResString.repoInvalid + override val repoEmpty: String get() = ResString.repoEmpty + override val noInternet: String get() = ResString.noInternet + override val loadImageUnavailable: String get() = ResString.loadImageUnavailable + override val lastImage: String get() = ResString.lastImage + override val firstImage: String get() = ResString.firstImage + override val picture: String get() = ResString.picture + override val size: String get() = ResString.size + override val pixels: String get() = ResString.pixels + override val refreshUnavailable: String get() = ResString.refreshUnavailable + } - override val httpClient: HttpClient = HttpClient(CIO) + override val httpClient: HttpClient = HttpClient(CIO) - val userHome: String? = System.getProperty("user.home") - override val imageRepository: ContentRepository = - createNetworkRepository(httpClient) - .run { - if (userHome != null) { - decorateWithDiskCache( - ioScope, - File(userHome).resolve("Pictures").resolve("imageviewer") - ) - } else { - this + val userHome: String? = System.getProperty("user.home") + override val imageRepository: ContentRepository = + createNetworkRepository(httpClient) + .run { + if (userHome != null) { + decorateWithDiskCache( + ioScope, + File(userHome).resolve("Pictures").resolve("imageviewer") + ) + } else { + this + } } - } - .adapter { it.toImageBitmap() } + .adapter { it.toImageBitmap() } - override val notification: Notification = object : PopupNotification(localization) { - override fun showPopUpMessage(text: String) { - toastState.value = ToastState.Shown(text) + override val notification: Notification = object : PopupNotification(localization) { + override fun showPopUpMessage(text: String) { + toastState.value = ToastState.Shown(text) + } } } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt index 23f214db17..b13adf4a34 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt @@ -1,7 +1,6 @@ package example.imageviewer.view import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType @@ -10,7 +9,7 @@ import example.imageviewer.model.ScalableState import example.imageviewer.model.addDragAmount import example.imageviewer.model.addScale -actual fun Modifier.addUserInput(state: MutableState): Modifier = +actual fun Modifier.addUserInput(state: ScalableState): Modifier = pointerInput(Unit) { detectDragGestures { change, dragAmount: Offset -> state.addDragAmount(dragAmount) diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt index 2f0eb17ab2..fd3a012438 100755 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt @@ -2,31 +2,33 @@ package example.imageviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import example.imageviewer.core.BitmapFilter import example.imageviewer.core.FilterType import example.imageviewer.model.ContentRepository -import example.imageviewer.model.State import example.imageviewer.model.adapter import example.imageviewer.model.createNetworkRepository import example.imageviewer.model.filtration.BlurFilter import example.imageviewer.model.filtration.GrayScaleFilter import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.style.ImageViewerTheme +import example.imageviewer.utils.ioDispatcher import example.imageviewer.view.Toast import example.imageviewer.view.ToastState -import io.ktor.client.* -import io.ktor.client.engine.darwin.* +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers @Composable internal fun ImageViewerIos() { val toastState = remember { mutableStateOf(ToastState.Hidden) } - val state = remember { mutableStateOf(State()) } - val ioScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Default } + val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher } val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } ImageViewerTheme { @@ -34,7 +36,6 @@ internal fun ImageViewerIos() { modifier = Modifier.fillMaxSize() ) { ImageViewerCommon( - state = state, dependencies = dependencies ) Toast(toastState) diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt new file mode 100644 index 0000000000..27a2a034df --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt @@ -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 \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt index 67889370aa..221df4bd14 100644 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt @@ -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): Modifier = +actual fun Modifier.addUserInput(state: ScalableState): Modifier = addTouchUserInput(state) diff --git a/experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar b/experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar index e708b1c023..249e5832f0 100644 Binary files a/experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar and b/experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar differ diff --git a/experimental/examples/visual-effects/gradlew b/experimental/examples/visual-effects/gradlew index 4f906e0c81..a69d9cb6c2 100755 --- a/experimental/examples/visual-effects/gradlew +++ b/experimental/examples/visual-effects/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,101 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/experimental/examples/visual-effects/gradlew.bat b/experimental/examples/visual-effects/gradlew.bat index 107acd32c4..f127cfd49d 100644 --- a/experimental/examples/visual-effects/gradlew.bat +++ b/experimental/examples/visual-effects/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal