Browse Source

Visual refresh for experimental Image Viewer (#2748)

* 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 view
pull/2761/head
Sebastian Aigner 2 years ago committed by GitHub
parent
commit
0dfba6086b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt
  2. BIN
      experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar
  3. 3
      experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties
  4. 12
      experimental/examples/imageviewer/gradlew
  5. 1
      experimental/examples/imageviewer/gradlew.bat
  6. 6
      experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj
  7. 2
      experimental/examples/imageviewer/shared/build.gradle.kts
  8. 7
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt
  9. 23
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt
  10. 3
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt
  11. 17
      experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml
  12. 16
      experimental/examples/imageviewer/shared/src/androidMain/res/values-ru/strings.xml
  13. 3
      experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml
  14. 65
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt
  15. 77
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/GalleryState.kt
  16. 21
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt
  17. 61
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt
  18. 85
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/State.kt
  19. 27
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt
  20. 5
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt
  21. 123
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt
  22. 11
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt
  23. 223
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt
  24. 57
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt
  25. 82
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt
  26. 5
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt
  27. 3
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt
  28. BIN
      experimental/examples/imageviewer/shared/src/commonMain/resources/list_view.png
  29. 32
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt
  30. 5
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt
  31. 41
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt
  32. 3
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt
  33. 17
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt
  34. 6
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt
  35. 3
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt
  36. BIN
      experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar
  37. 263
      experimental/examples/visual-effects/gradlew
  38. 14
      experimental/examples/visual-effects/gradlew.bat

3
experimental/examples/imageviewer/desktopApp/src/jvmMain/kotlin/example/imageviewer/Main.kt

@ -1,11 +1,8 @@
package example.imageviewer package example.imageviewer
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import example.imageviewer.view.ImageViewerDesktop import example.imageviewer.view.ImageViewerDesktop
fun main() = application { fun main() = application {
MaterialTheme {
ImageViewerDesktop() ImageViewerDesktop()
}
} }

BIN
experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar vendored

Binary file not shown.

3
experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties vendored

@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

12
experimental/examples/imageviewer/gradlew vendored

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -80,10 +80,10 @@ do
esac esac
done done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # This is normally unused
# shellcheck disable=SC2034
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} 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. # 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"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) 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 ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
case $MAX_FD in #( case $MAX_FD in #(
'' | soft) :;; #( '' | 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" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac

1
experimental/examples/imageviewer/gradlew.bat vendored

@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%

6
experimental/examples/imageviewer/iosApp/iosApp.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 50; objectVersion = 51;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -340,7 +340,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}"; PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}";
PRODUCT_NAME = "Imageviewer"; PRODUCT_NAME = Imageviewer;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -364,7 +364,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}"; PRODUCT_BUNDLE_IDENTIFIER = "org.jetbrains.Imageviewer${TEAM_ID}";
PRODUCT_NAME = "Imageviewer"; PRODUCT_NAME = Imageviewer;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

2
experimental/examples/imageviewer/shared/build.gradle.kts

@ -36,6 +36,8 @@ kotlin {
implementation(compose.material) implementation(compose.material)
implementation("org.jetbrains.compose.components:components-resources:1.3.0-beta04-dev879") implementation("org.jetbrains.compose.components:components-resources:1.3.0-beta04-dev879")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.material3)
} }
} }
val androidMain by getting { val androidMain by getting {

7
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

23
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.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext 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.BitmapFilter
import example.imageviewer.core.FilterType import example.imageviewer.core.FilterType
import example.imageviewer.model.ContentRepository import example.imageviewer.model.ContentRepository
import example.imageviewer.model.State
import example.imageviewer.model.adapter import example.imageviewer.model.adapter
import example.imageviewer.model.createNetworkRepository import example.imageviewer.model.createNetworkRepository
import example.imageviewer.model.filtration.BlurFilter 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.model.filtration.PixelFilter
import example.imageviewer.shared.R import example.imageviewer.shared.R
import example.imageviewer.style.ImageViewerTheme import example.imageviewer.style.ImageViewerTheme
import io.ktor.client.* import example.imageviewer.toImageBitmap
import io.ktor.client.engine.okhttp.* import example.imageviewer.utils.ioDispatcher
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Composable @Composable
fun ImageViewerAndroid() { fun ImageViewerAndroid() {
val context: Context = LocalContext.current val context: Context = LocalContext.current
val ioScope = rememberCoroutineScope { Dispatchers.IO } val ioScope = rememberCoroutineScope { ioDispatcher }
val dependencies = remember(context, ioScope) { getDependencies(context, ioScope) } val dependencies = remember(context, ioScope) { getDependencies(context, ioScope) }
val state = remember { mutableStateOf(State()) }
ImageViewerTheme { 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 val notification: Notification = object : PopupNotification(localization) {
override fun showPopUpMessage(text: String) { 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()
} }
} }
}
} }

3
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt

@ -1,8 +1,7 @@
package example.imageviewer.view package example.imageviewer.view
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import example.imageviewer.model.ScalableState import example.imageviewer.model.ScalableState
actual fun Modifier.addUserInput(state: MutableState<ScalableState>) = actual fun Modifier.addUserInput(state: ScalableState) =
addTouchUserInput(state) addTouchUserInput(state)

17
experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml

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

16
experimental/examples/imageviewer/shared/src/androidMain/res/values-ru/strings.xml

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

3
experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml

@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">ImageViewer</string> <string name="app_name">My Memories</string>
<string name="loading">Loading images...</string> <string name="loading">Loading images...</string>
<string name="repo_empty">Repository is empty.</string> <string name="repo_empty">Repository is empty.</string>
<string name="no_internet">No internet access.</string> <string name="no_internet">No internet access.</string>
@ -12,4 +12,5 @@
<string name="size">Size:</string> <string name="size">Size:</string>
<string name="pixels">pixels.</string> <string name="pixels">pixels.</string>
<string name="back">back</string> <string name="back">back</string>
<string name="refresh">Refresh</string>
</resources> </resources>

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

@ -1,35 +1,72 @@
package example.imageviewer package example.imageviewer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import example.imageviewer.model.* import example.imageviewer.model.GalleryScreenState
import example.imageviewer.model.ScreenState
import example.imageviewer.model.bigUrl
import example.imageviewer.view.FullscreenImage import example.imageviewer.view.FullscreenImage
import example.imageviewer.view.MainScreen import example.imageviewer.view.MainScreen
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
enum class ExternalImageViewerEvent {
Foward,
Back
}
@Composable @Composable
internal fun ImageViewerCommon(state: MutableState<State>, dependencies: Dependencies) { internal fun ImageViewerCommon(
state.refresh(dependencies) dependencies: Dependencies,
externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow()
) {
val galleryScreenState = remember { GalleryScreenState() }
LaunchedEffect(Unit) {
galleryScreenState.refresh(dependencies)
}
LaunchedEffect(Unit) {
externalEvents.collect {
when (it) {
ExternalImageViewerEvent.Foward -> galleryScreenState.nextImage()
ExternalImageViewerEvent.Back -> galleryScreenState.previousImage()
}
}
}
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
when (state.value.screen) { AnimatedVisibility(
ScreenState.Miniatures -> { galleryScreenState.screen == ScreenState.Miniatures,
MainScreen(state, dependencies) enter = fadeIn(),
exit = fadeOut()
) {
MainScreen(galleryScreenState, dependencies)
} }
ScreenState.FullScreen -> { AnimatedVisibility(
galleryScreenState.screen == ScreenState.FullScreen,
enter = slideInHorizontally { -it },
exit = slideOutHorizontally { -it }) {
FullscreenImage( FullscreenImage(
picture = state.value.picture, picture = galleryScreenState.picture,
getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, getImage = { dependencies.imageRepository.loadContent(it.bigUrl) },
getFilter = { dependencies.getFilter(it) }, getFilter = { dependencies.getFilter(it) },
localization = dependencies.localization, localization = dependencies.localization,
back = { state.value = state.value.copy(screen = ScreenState.Miniatures) }, back = {
nextImage = { state.nextImage() }, galleryScreenState.screen = ScreenState.Miniatures
previousImage = { state.previousImage() }, },
nextImage = { galleryScreenState.nextImage() },
previousImage = { galleryScreenState.previousImage() },
) )
} }
} }
}
} }

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

21
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) data class Picture(val big: String, val small: String)
fun getNameURL(url: String): String = url.substring(url.lastIndexOf('/') + 1, url.length) 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.bigUrl get() = "$BASE_URL/$big"
val Picture.smallUrl get() = "$BASE_URL/$small" 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"
)

61
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt

@ -1,17 +1,18 @@
package example.imageviewer.model 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.geometry.Offset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
data class ScalableState( class ScalableState(val imageSize: IntSize) {
val imageSize: IntSize, var boxSize by mutableStateOf(IntSize(1, 1))
val boxSize: IntSize = IntSize(1, 1), var offset by mutableStateOf(IntOffset.Zero)
val offset: IntOffset = IntOffset.Zero, var scale by mutableStateOf(1f)
val scale: Float = 1f }
)
val ScalableState.visiblePart val ScalableState.visiblePart
get() : IntRect { get() : IntRect {
@ -32,46 +33,46 @@ val ScalableState.visiblePart
return IntRect(offset = offset, size = size) return IntRect(offset = offset, size = size)
} }
fun MutableState<ScalableState>.changeBoxSize(size: IntSize) = modifyState { fun ScalableState.changeBoxSize(size: IntSize) {
copy(boxSize = size) boxSize = size
.updateOffsetLimits() updateOffsetLimits()
} }
fun MutableState<ScalableState>.setScale(scale: Float) = modifyState { fun ScalableState.setScale(scale: Float) {
copy(scale = scale) this.scale = scale
.updateOffsetLimits()
} }
fun MutableState<ScalableState>.addScale(diff: Float) = modifyState { fun ScalableState.addScale(diff: Float) {
if (scale + diff > MAX_SCALE) { scale = if (scale + diff > MAX_SCALE) {
copy(scale = MAX_SCALE) MAX_SCALE
} else if (scale + diff < MIN_SCALE) { } else if (scale + diff < MIN_SCALE) {
copy(scale = MIN_SCALE) MIN_SCALE
} else { } else {
copy(scale = scale + diff) scale + diff
}.updateOffsetLimits() }
updateOffsetLimits()
} }
fun MutableState<ScalableState>.addDragAmount(diff: Offset) = modifyState { fun ScalableState.addDragAmount(diff: Offset) {
copy(offset = offset - IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt())) offset -= IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt())
.updateOffsetLimits() updateOffsetLimits()
} }
private fun ScalableState.updateOffsetLimits(): ScalableState { private fun ScalableState.updateOffsetLimits() {
var result = this
if (offset.x + visiblePart.width > imageSize.width) { 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) { 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) { if (offset.x < 0) {
result = result.changeOffset(x = 0) changeOffset(x = 0)
} }
if (offset.y < 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)
}

85
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/State.kt

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

27
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt

@ -1,8 +1,9 @@
package example.imageviewer.style package example.imageviewer.style
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
object ImageviewerColors { object ImageviewerColors {
@ -18,6 +19,18 @@ object ImageviewerColors {
val TranslucentWhite = Color(255, 255, 255, 20) val TranslucentWhite = Color(255, 255, 255, 20)
val Transparent = Color.Transparent 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 fun buttonBackground(isHover: Boolean) = if (isHover) TranslucentBlack else Transparent
} }
@ -25,15 +38,9 @@ object ImageviewerColors {
internal fun ImageViewerTheme(content: @Composable () -> Unit) { internal fun ImageViewerTheme(content: @Composable () -> Unit) {
isSystemInDarkTheme() // todo check and change colors isSystemInDarkTheme() // todo check and change colors
MaterialTheme( MaterialTheme(
colors = MaterialTheme.colors.copy( colorScheme = MaterialTheme.colorScheme.copy(
primary = ImageviewerColors.Foreground, background = Color(0xFF1B1B1B),
secondary = ImageviewerColors.LightGray, onBackground = Color(0xFFFFFFFF)
background = ImageviewerColors.DarkGray,
surface = ImageviewerColors.Gray,
onPrimary = ImageviewerColors.Foreground,
onSecondary = Color.Black,
onBackground = ImageviewerColors.Foreground,
onSurface = ImageviewerColors.Foreground
) )
) { ) {
content() content()

5
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

123
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.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
@ -41,7 +40,8 @@ internal fun FullscreenImage(
nextImage: () -> Unit, nextImage: () -> Unit,
previousImage: () -> Unit, previousImage: () -> Unit,
) { ) {
val filtersState = remember { mutableStateOf(emptySet<FilterType>()) } val availableFilters = FilterType.values().toList()
var selectedFilters by remember { mutableStateOf(emptySet<FilterType>()) }
val originalImageState = remember(picture) { mutableStateOf<ImageBitmap?>(null) } val originalImageState = remember(picture) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(picture) { LaunchedEffect(picture) {
@ -51,11 +51,10 @@ internal fun FullscreenImage(
} }
val originalImage = originalImageState.value val originalImage = originalImageState.value
val filters = filtersState.value val imageWithFilter = remember(originalImage, selectedFilters) {
val imageWithFilter = remember(originalImage, filters) {
if (originalImage != null) { if (originalImage != null) {
var result: ImageBitmap = originalImage var result: ImageBitmap = originalImage
for (filter in filters.map { getFilter(it) }) { for (filter in selectedFilters.map { getFilter(it) }) {
result = filter.apply(result) result = filter.apply(result)
} }
result result
@ -63,17 +62,28 @@ internal fun FullscreenImage(
null null
} }
} }
Box(Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background)) {
Box(Modifier.fillMaxSize().background(color = MaterialTheme.colors.background)) {
Column { 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) { if (imageWithFilter != null) {
val imageSize = IntSize(imageWithFilter.width, imageWithFilter.height) val imageSize = IntSize(imageWithFilter.width, imageWithFilter.height)
val scalableState = remember(imageSize) { mutableStateOf(ScalableState(imageSize)) } val scalableState = remember(imageSize) { ScalableState(imageSize) }
val visiblePartOfImage: IntRect = scalableState.value.visiblePart val visiblePartOfImage: IntRect = scalableState.visiblePart
Slider( Slider(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = scalableState.value.scale, value = scalableState.scale,
valueRange = MIN_SCALE..MAX_SCALE, valueRange = MIN_SCALE..MAX_SCALE,
onValueChange = { scalableState.setScale(it) }, 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( Icon(
imageVector = Icons.Filled.KeyboardArrowLeft, imageVector = Icons.Filled.KeyboardArrowLeft,
contentDescription = "Previous", 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( Icon(
imageVector = Icons.Filled.KeyboardArrowRight, imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = "Next", contentDescription = "Next",
tint = MaterialTheme.colors.primary tint = MaterialTheme.colorScheme.onBackground
) )
} }
} }
} }
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalResourceApi::class)
@Composable @Composable
private fun Toolbar( private fun FullscreenImageBar(
title: String,
filtersState: MutableState<Set<FilterType>>,
localization: Localization, localization: Localization,
back: () -> Unit pictureName: String?,
onBack: () -> Unit,
filters: List<FilterType>,
selectedFilters: Set<FilterType>,
onSelectFilter: (FilterType) -> Unit
) { ) {
val backButtonInteractionSource = remember { MutableInteractionSource() } TopAppBar(
val backButtonHover by backButtonInteractionSource.collectIsHoveredAsState() modifier = Modifier.background(brush = ImageviewerColors.kotlinHorizontalGradientBrush),
Surface( colors = TopAppBarDefaults.smallTopAppBarColors(
modifier = Modifier.height(44.dp) containerColor = ImageviewerColors.Transparent,
) { titleContentColor = MaterialTheme.colorScheme.onBackground
Row(modifier = Modifier.padding(end = 30.dp)) { ),
Surface( title = {
color = Color.Transparent, Text("${localization.picture} ${pictureName ?: "Unknown"}")
modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically), },
shape = CircleShape navigationIcon = {
) {
Tooltip(localization.back) { Tooltip(localization.back) {
Image( Image(
resource("back.png").rememberImageBitmap().orEmpty(), resource("back.png").rememberImageBitmap().orEmpty(),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(38.dp) modifier = Modifier.size(38.dp)
.hoverable(backButtonInteractionSource) .clip(CircleShape)
.background(color = ImageviewerColors.buttonBackground(backButtonHover)) .clickable { onBack() }
.clickable { back() }
) )
} }
} },
Text( actions = {
title, for (type in filters) {
maxLines = 1, FilterButton(active = type in selectedFilters,
modifier = Modifier.padding(start = 30.dp).weight(1f) type,
.align(Alignment.CenterVertically), onClick = {
style = MaterialTheme.typography.body1 onSelectFilter(type)
)
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
}
}) })
} }
} }
} )
}
}
} }
@Composable @Composable
private fun FilterButton( private fun FilterButton(
active: Boolean, active: Boolean,

11
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.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material3.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -15,10 +12,10 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
internal fun LoadingScreen(text: String = "") { internal fun LoadingScreen(text: String = "") {
Box( Box(
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colors.background) modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background)
) { ) {
Box(modifier = Modifier.align(Alignment.Center)) { Box(modifier = Modifier.align(Alignment.Center)) {
Surface(elevation = 4.dp, shape = CircleShape) { Surface(/*elevation = 4.dp, */shape = CircleShape) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(50.dp).padding(3.dp, 3.dp, 4.dp, 4.dp) 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 = text, text = text,
modifier = Modifier.align(Alignment.Center).offset(0.dp, 70.dp), modifier = Modifier.align(Alignment.Center).offset(0.dp, 70.dp),
style = MaterialTheme.typography.body1 style = MaterialTheme.typography.bodyMedium
) )
} }
} }

223
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MainScreen.kt

@ -1,72 +1,229 @@
package example.imageviewer.view package example.imageviewer.view
import androidx.compose.foundation.* import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.* 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.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import example.imageviewer.Dependencies import example.imageviewer.Dependencies
import example.imageviewer.model.* import example.imageviewer.model.GalleryScreenState
import example.imageviewer.model.State import example.imageviewer.model.Picture
import example.imageviewer.style.* 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.ExperimentalResourceApi
import org.jetbrains.compose.resources.orEmpty import org.jetbrains.compose.resources.orEmpty
import org.jetbrains.compose.resources.rememberImageBitmap import org.jetbrains.compose.resources.rememberImageBitmap
import org.jetbrains.compose.resources.resource import org.jetbrains.compose.resources.resource
@Composable @Composable
internal fun MainScreen(state: MutableState<State>, dependencies: Dependencies) { internal fun GalleryHeader() {
Column { Row(
TopContent(state, dependencies) 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 (!galleryScreenState.isContentReady) {
LoadingScreen(dependencies.localization.loading)
}
}
@Composable
private fun SquaresGalleryView(
images: List<PictureWithThumbnail>,
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)
}
}
}
@Composable
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<PictureWithThumbnail>,
dependencies: Dependencies,
onSelect: (Picture) -> Unit,
onFullScreen: (Int) -> Unit
) {
GalleryHeader()
Spacer(modifier = Modifier.height(10.dp))
ScrollableColumn( ScrollableColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
for (i in state.value.pictures.indices) { for ((idx, picWithThumb) in pictures.withIndex()) {
val picture = state.value.pictures[i] val (picture, miniature) = picWithThumb
Miniature( Miniature(
picture = picture, picture = picture,
image = state.value.miniatures[picture], image = miniature,
onClickSelect = { onClickSelect = {
state.setSelectedIndex(i) onSelect(picture)
}, },
onClickFullScreen = { onClickFullScreen = {
state.toFullscreen(i) onFullScreen(idx)
}, },
onClickInfo = { onClickInfo = {
dependencies.notification.notifyImageData(picture) dependencies.notification.notifyImageData(picture)
}, },
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(10.dp))
} }
} }
}
if (!state.value.isContentReady) {
LoadingScreen(dependencies.localization.loading)
}
} }
@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopContent(state: MutableState<State>, dependencies: Dependencies) { private fun TitleBar(onRefresh: () -> Unit, onToggle: () -> Unit, dependencies: Dependencies) {
TitleBar(state, dependencies)
if (needShowPreview()) {
PreviewImage(state = state, getImage = { dependencies.imageRepository.loadContent(it.bigUrl) })
}
}
@OptIn(ExperimentalResourceApi::class)
@Composable
private fun TitleBar(state: MutableState<State>, dependencies: Dependencies) {
TopAppBar( TopAppBar(
backgroundColor = MaterialTheme.colors.surface, modifier = Modifier.background(brush = kotlinHorizontalGradientBrush),
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = ImageviewerColors.Transparent,
titleContentColor = MaterialTheme.colorScheme.onBackground
),
title = { title = {
Row(Modifier.height(50.dp)) { Row(Modifier.height(50.dp)) {
Text( Text(
dependencies.localization.appName, 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( Surface(
color = ImageviewerColors.Transparent, color = ImageviewerColors.Transparent,
modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically), modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically),
@ -76,7 +233,7 @@ private fun TitleBar(state: MutableState<State>, dependencies: Dependencies) {
bitmap = resource("refresh.png").rememberImageBitmap().orEmpty(), bitmap = resource("refresh.png").rememberImageBitmap().orEmpty(),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(35.dp).clickable { modifier = Modifier.size(35.dp).clickable {
state.refresh(dependencies) onRefresh()
} }
) )
} }

57
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt

@ -1,24 +1,38 @@
package example.imageviewer.view package example.imageviewer.view
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Row
import androidx.compose.material.* import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier 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.ImageBitmap
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import example.imageviewer.model.* import example.imageviewer.model.Picture
import example.imageviewer.style.* import example.imageviewer.model.name
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.orEmpty import org.jetbrains.compose.resources.orEmpty
import org.jetbrains.compose.resources.rememberImageBitmap import org.jetbrains.compose.resources.rememberImageBitmap
import org.jetbrains.compose.resources.resource import org.jetbrains.compose.resources.resource
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun Miniature( internal fun Miniature(
picture: Picture, picture: Picture,
@ -28,24 +42,28 @@ internal fun Miniature(
onClickInfo: () -> Unit, onClickInfo: () -> Unit,
) { ) {
Card( Card(
backgroundColor = ImageviewerColors.MiniatureColor,
modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp) modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp)
.fillMaxWidth() .fillMaxWidth(),
.clickable { onClick = { onClickSelect() },
onClickSelect() shape = RoundedCornerShape(200.dp),
}, border = BorderStroke(1.dp, Color.White),
shape = RectangleShape, colors = CardDefaults.cardColors(
elevation = 2.dp containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground
)
) { ) {
Row(modifier = Modifier.padding(end = 30.dp)) { Row(modifier = Modifier.padding(end = 30.dp)) {
val modifier = Modifier.height(70.dp) val modifier = Modifier.height(70.dp)
.width(90.dp) .width(70.dp)
.padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp)
if (image != null) { if (image != null) {
Image( Image(
image, image,
contentDescription = null, contentDescription = null,
modifier = modifier.clickable { onClickFullScreen() }, modifier = modifier
.clip(CircleShape)
.border(BorderStroke(1.dp, Color.White), CircleShape)
.clickable { onClickFullScreen() },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else { } else {
@ -53,8 +71,9 @@ internal fun Miniature(
} }
Text( Text(
text = picture.name, text = picture.name,
modifier = Modifier.weight(1f).align(Alignment.CenterVertically).padding(start = 16.dp), modifier = Modifier.weight(1f).align(Alignment.CenterVertically)
style = MaterialTheme.typography.body1 .padding(start = 16.dp),
style = MaterialTheme.typography.titleLarge
) )
Image( Image(

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

@ -1,52 +1,82 @@
package example.imageviewer.view 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.* import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.* 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.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import example.imageviewer.model.* import example.imageviewer.model.Picture
import example.imageviewer.model.State import example.imageviewer.style.ImageviewerColors.kotlinHorizontalGradientBrush
import example.imageviewer.style.*
import org.jetbrains.compose.resources.ExperimentalResourceApi 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 @Composable
internal fun PreviewImage(state: MutableState<State>, getImage: suspend (Picture) -> ImageBitmap) { internal fun PreviewImage(
val pictures = state.value.pictures picture: Picture?,
val index = state.value.currentImageIndex onClick: () -> Unit,
val imageState = remember(pictures, index) { mutableStateOf<ImageBitmap?>(null) } getImage: suspend (Picture) -> ImageBitmap
LaunchedEffect(pictures, index) { ) {
val picture = pictures.getOrNull(index) var image by remember(picture) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(picture) {
if (picture != null) { 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( Card(
backgroundColor = MaterialTheme.colors.background,
modifier = Modifier.height(200.dp) modifier = Modifier.height(200.dp)
.clickable { state.toFullscreen() }, .background(brush = kotlinHorizontalGradientBrush)
shape = RectangleShape, .padding(10.dp)
elevation = 1.dp .clickable { onClick() },
shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp),
) { ) {
AnimatedContent(
targetState = image,
transitionSpec = {
slideInVertically(initialOffsetY = { it }) with slideOutVertically(targetOffsetY = { -it })
}
) { imageBitmap ->
if (imageBitmap != null) {
Image( Image(
bitmap = image ?: resource("empty.png").rememberImageBitmap().orEmpty(), bitmap = imageBitmap,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp), .fillMaxSize()
contentScale = ContentScale.Fit ,
contentScale = ContentScale.Crop
)
} else {
Spacer(
modifier = Modifier.fillMaxSize()
.background(brush = kotlinHorizontalGradientBrush)
) )
} }
}
}
} }
@Composable @Composable

5
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.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import example.imageviewer.model.ScalableState import example.imageviewer.model.ScalableState
@ -10,9 +9,9 @@ import example.imageviewer.model.addDragAmount
import example.imageviewer.model.addScale import example.imageviewer.model.addScale
import example.imageviewer.model.setScale import example.imageviewer.model.setScale
expect fun Modifier.addUserInput(state: MutableState<ScalableState>): Modifier expect fun Modifier.addUserInput(state: ScalableState): Modifier
fun Modifier.addTouchUserInput(state: MutableState<ScalableState>): Modifier = fun Modifier.addTouchUserInput(state: ScalableState): Modifier =
pointerInput(Unit) { pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ -> detectTransformGestures { _, pan, zoom, _ ->
state.addDragAmount(pan) state.addDragAmount(pan)

3
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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Surface import androidx.compose.material3.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

32
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt

@ -18,23 +18,23 @@ object ResString {
val refresh: String val refresh: String
init { init {
if (System.getProperty("user.language").equals("ru")) { if (System.getProperty("user.language").equals("de")) {
appName = "ImageViewer" appName = "Meine Erinnerungen"
loading = "Загружаем изображения..." loading = "Bilder werden geladen..."
repoEmpty = "Репозиторий пуст." repoEmpty = "Bildverzeichnis ist leer."
noInternet = "Нет доступа в интернет." noInternet = "Kein Internetzugriff."
repoInvalid = "Список изображений в репозитории пуст или имеет неверный формат." repoInvalid = "Bildverzeichnis beschädigt oder leer."
refreshUnavailable = "Невозможно обновить изображения." refreshUnavailable = "Kann Bilder nicht aktualisieren."
loadImageUnavailable = "Невозможно загузить полное изображение." loadImageUnavailable = "Kann volles Bild nicht laden."
lastImage = "Это последнее изображение." lastImage = "Dies ist das letzte Bild."
firstImage = "Это первое изображение." firstImage = "Dies ist das erste Bild."
picture = "Изображение:" picture = "Bild:"
size = "Размеры:" size = "Abmessungen:"
pixels = "пикселей." pixels = "Pixel."
back = "Назад" back = "Zurück"
refresh = "Обновить" refresh = "Aktualisieren"
} else { } else {
appName = "ImageViewer" appName = "My Memories"
loading = "Loading images..." loading = "Loading images..."
repoEmpty = "Repository is empty." repoEmpty = "Repository is empty."
noInternet = "No internet access." noInternet = "No internet access."

5
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

41
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.BitmapFilter
import example.imageviewer.core.FilterType import example.imageviewer.core.FilterType
import example.imageviewer.model.* import example.imageviewer.model.*
import example.imageviewer.model.State
import example.imageviewer.model.filtration.BlurFilter import example.imageviewer.model.filtration.BlurFilter
import example.imageviewer.model.filtration.GrayScaleFilter import example.imageviewer.model.filtration.GrayScaleFilter
import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.model.filtration.PixelFilter
import example.imageviewer.style.ImageViewerTheme import example.imageviewer.style.ImageViewerTheme
import example.imageviewer.utils.decorateWithDiskCache import example.imageviewer.utils.decorateWithDiskCache
import example.imageviewer.utils.getPreferredWindowSize import example.imageviewer.utils.getPreferredWindowSize
import example.imageviewer.utils.ioDispatcher
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import kotlinx.coroutines.CoroutineScope 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 import java.io.File
class ExternalNavigationEventBus {
private val _events = MutableSharedFlow<ExternalImageViewerEvent>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_LATEST
)
val events = _events.asSharedFlow()
fun produceEvent(event: ExternalImageViewerEvent) {
_events.tryEmit(event)
}
}
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun ApplicationScope.ImageViewerDesktop() { fun ApplicationScope.ImageViewerDesktop() {
val toastState = remember { mutableStateOf<ToastState>(ToastState.Hidden) } val toastState = remember { mutableStateOf<ToastState>(ToastState.Hidden) }
val state = remember { mutableStateOf(State()) } val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher }
val ioScope: CoroutineScope = rememberCoroutineScope { Dispatchers.IO }
val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) }
val externalNavigationEventBus = remember { ExternalNavigationEventBus() }
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
@ -47,11 +61,17 @@ fun ApplicationScope.ImageViewerDesktop() {
size = getPreferredWindowSize(800, 1000) size = getPreferredWindowSize(800, 1000)
), ),
icon = painterResource("ic_imageviewer_round.png"), icon = painterResource("ic_imageviewer_round.png"),
// https://github.com/JetBrains/compose-jb/issues/2741
onKeyEvent = { onKeyEvent = {
if (it.type == KeyEventType.KeyUp) { if (it.type == KeyEventType.KeyUp) {
when (it.key) { when (it.key) {
Key.DirectionLeft -> state.previousImage() Key.DirectionLeft -> externalNavigationEventBus.produceEvent(
Key.DirectionRight -> state.nextImage() ExternalImageViewerEvent.Back
)
Key.DirectionRight -> externalNavigationEventBus.produceEvent(
ExternalImageViewerEvent.Foward
)
} }
} }
false false
@ -62,8 +82,8 @@ fun ApplicationScope.ImageViewerDesktop() {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
ImageViewerCommon( ImageViewerCommon(
state = state, dependencies = dependencies,
dependencies = dependencies externalEvents = externalNavigationEventBus.events
) )
Toast(toastState) Toast(toastState)
} }
@ -71,7 +91,8 @@ fun ApplicationScope.ImageViewerDesktop() {
} }
} }
private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState<ToastState>) = object : Dependencies { private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState<ToastState>) =
object : Dependencies {
override val ioScope: CoroutineScope = ioScope override val ioScope: CoroutineScope = ioScope
override fun getFilter(type: FilterType): BitmapFilter = when (type) { override fun getFilter(type: FilterType): BitmapFilter = when (type) {
FilterType.GrayScale -> GrayScaleFilter() FilterType.GrayScale -> GrayScaleFilter()
@ -117,4 +138,4 @@ private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState<To
toastState.value = ToastState.Shown(text) toastState.value = ToastState.Shown(text)
} }
} }
} }

3
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt

@ -1,7 +1,6 @@
package example.imageviewer.view package example.imageviewer.view
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventType 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.addDragAmount
import example.imageviewer.model.addScale import example.imageviewer.model.addScale
actual fun Modifier.addUserInput(state: MutableState<ScalableState>): Modifier = actual fun Modifier.addUserInput(state: ScalableState): Modifier =
pointerInput(Unit) { pointerInput(Unit) {
detectDragGestures { change, dragAmount: Offset -> detectDragGestures { change, dragAmount: Offset ->
state.addDragAmount(dragAmount) state.addDragAmount(dragAmount)

17
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.foundation.layout.fillMaxSize
import androidx.compose.material.Surface 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.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import example.imageviewer.core.BitmapFilter import example.imageviewer.core.BitmapFilter
import example.imageviewer.core.FilterType import example.imageviewer.core.FilterType
import example.imageviewer.model.ContentRepository import example.imageviewer.model.ContentRepository
import example.imageviewer.model.State
import example.imageviewer.model.adapter import example.imageviewer.model.adapter
import example.imageviewer.model.createNetworkRepository import example.imageviewer.model.createNetworkRepository
import example.imageviewer.model.filtration.BlurFilter import example.imageviewer.model.filtration.BlurFilter
import example.imageviewer.model.filtration.GrayScaleFilter import example.imageviewer.model.filtration.GrayScaleFilter
import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.model.filtration.PixelFilter
import example.imageviewer.style.ImageViewerTheme import example.imageviewer.style.ImageViewerTheme
import example.imageviewer.utils.ioDispatcher
import example.imageviewer.view.Toast import example.imageviewer.view.Toast
import example.imageviewer.view.ToastState import example.imageviewer.view.ToastState
import io.ktor.client.* import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.* import io.ktor.client.engine.darwin.Darwin
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@Composable @Composable
internal fun ImageViewerIos() { internal fun ImageViewerIos() {
val toastState = remember { mutableStateOf<ToastState>(ToastState.Hidden) } val toastState = remember { mutableStateOf<ToastState>(ToastState.Hidden) }
val state = remember { mutableStateOf(State()) } val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher }
val ioScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Default }
val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) }
ImageViewerTheme { ImageViewerTheme {
@ -34,7 +36,6 @@ internal fun ImageViewerIos() {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
ImageViewerCommon( ImageViewerCommon(
state = state,
dependencies = dependencies dependencies = dependencies
) )
Toast(toastState) Toast(toastState)

6
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

3
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt

@ -1,8 +1,7 @@
package example.imageviewer.view package example.imageviewer.view
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import example.imageviewer.model.ScalableState import example.imageviewer.model.ScalableState
actual fun Modifier.addUserInput(state: MutableState<ScalableState>): Modifier = actual fun Modifier.addUserInput(state: ScalableState): Modifier =
addTouchUserInput(state) addTouchUserInput(state)

BIN
experimental/examples/visual-effects/gradle/wrapper/gradle-wrapper.jar vendored

Binary file not shown.

263
experimental/examples/visual-effects/gradlew vendored

@ -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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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 # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
PRG="$0" app_path=$0
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do # Need this for daisy-chained symlinks.
ls=`ls -ld "$PRG"` while
link=`expr "$ls" : '.*-> \(.*\)$'` APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
if expr "$link" : '/.*' > /dev/null; then [ -h "$app_path" ]
PRG="$link" do
else ls=$( ls -ld "$app_path" )
PRG=`dirname "$PRG"`"/$link" link=${ls#*' -> '}
fi case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" 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. # 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"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum" MAX_FD=maximum
warn () { warn () {
echo "$*" echo "$*"
} } >&2
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} } >&2
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "`uname`" in case "$( uname )" in #(
CYGWIN* ) CYGWIN* ) cygwin=true ;; #(
cygwin=true Darwin* ) darwin=true ;; #(
;; MSYS* | MINGW* ) msys=true ;; #(
Darwin* ) NONSTOP* ) nonstop=true ;;
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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 [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java" JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD="$JAVA_HOME/bin/java" JAVACMD=$JAVA_HOME/bin/java
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 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." location of your Java installation."
fi fi
else 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. 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 Please set the JAVA_HOME variable in your environment to match the
@ -106,80 +140,101 @@ location of your Java installation."
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
MAX_FD_LIMIT=`ulimit -H -n` case $MAX_FD in #(
if [ $? -eq 0 ] ; then max*)
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD=$( ulimit -H -n ) ||
MAX_FD="$MAX_FD_LIMIT" warn "Could not query maximum file descriptor limit"
fi esac
ulimit -n $MAX_FD case $MAX_FD in #(
if [ $? -ne 0 ] ; then '' | soft) :;; #(
warn "Could not set maximum file descriptor limit: $MAX_FD" *)
fi ulimit -n "$MAX_FD" ||
else warn "Could not set maximum file descriptor limit to $MAX_FD"
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" esac
fi
fi fi
# For Darwin, add options to specify how the application appears in the dock # Collect all arguments for the java command, stacking in reverse order:
if $darwin; then # * args from the command line
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" # * the main class name
fi # * -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 # For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then if "$cygwin" || "$msys" ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"` APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 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
# 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 JAVACMD=$( cygpath --unix "$JAVACMD" )
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else # Now convert the arguments - kludge to limit ourselves to /bin/sh
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 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 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 fi
# Escape application args # Collect all arguments for the java command;
save () { # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done # shell script including quotes and variable substitutions, so put them in
echo " " # double quotes to make sure that they get re-expanded; and
} # * put everything else in single quotes, so that it's not re-expanded.
APP_ARGS=`save "$@"`
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 -- $(
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

14
experimental/examples/visual-effects/gradlew.bat vendored

@ -14,7 +14,7 @@
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%" == "" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 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 :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal

Loading…
Cancel
Save