Browse Source

Copy imageviewer to experimental/examples (#2500)

pull/2508/head
dima.avdeev 2 years ago committed by GitHub
parent
commit
e8786ea73f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      experimental/examples/imageviewer/.gitignore
  2. 21
      experimental/examples/imageviewer/.run/desktop.run.xml
  3. 18
      experimental/examples/imageviewer/README.md
  4. 26
      experimental/examples/imageviewer/android/build.gradle.kts
  5. 28
      experimental/examples/imageviewer/android/src/main/AndroidManifest.xml
  6. 23
      experimental/examples/imageviewer/android/src/main/java/example/imageviewer/MainActivity.kt
  7. 18
      experimental/examples/imageviewer/build.gradle.kts
  8. 54
      experimental/examples/imageviewer/common/build.gradle.kts
  9. 2
      experimental/examples/imageviewer/common/src/androidMain/AndroidManifest.xml
  10. 7
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/core/BitmapFilter.kt
  11. 383
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/AndroidContentState.kt
  12. 131
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/ImageHandler.kt
  13. 12
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/Picture.kt
  14. 13
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt
  15. 12
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt
  16. 54
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt
  17. 12
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt
  18. 12
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt
  19. 38
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/style/Decoration.kt
  20. 52
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Caching.kt
  21. 9
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Coroutines.kt
  22. 195
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.kt
  23. 40
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/AppUI.kt
  24. 197
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/FullscreenImage.kt
  25. 218
      experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/MainScreen.kt
  26. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png
  27. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_off.png
  28. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_on.png
  29. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png
  30. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png
  31. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/filter_unknown.png
  32. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_off.png
  33. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_on.png
  34. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_off.png
  35. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_on.png
  36. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/drawable/refresh.png
  37. 5
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer.xml
  38. 5
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer_round.xml
  39. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer.png
  40. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_background.png
  41. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_foreground.png
  42. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_round.png
  43. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer.png
  44. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_background.png
  45. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_foreground.png
  46. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_round.png
  47. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer.png
  48. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_background.png
  49. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_foreground.png
  50. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_round.png
  51. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer.png
  52. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_background.png
  53. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_foreground.png
  54. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_round.png
  55. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer.png
  56. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_background.png
  57. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_foreground.png
  58. BIN
      experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_round.png
  59. 15
      experimental/examples/imageviewer/common/src/androidMain/res/values-ru/strings.xml
  60. 14
      experimental/examples/imageviewer/common/src/androidMain/res/values/strings.xml
  61. 18
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/EventLocker.kt
  62. 5
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt
  63. 5
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/Repository.kt
  64. 33
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ImageRepository.kt
  65. 41
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/Miniatures.kt
  66. 23
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ScreenType.kt
  67. 16
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/style/Palette.kt
  68. 7
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Coroutines.kt
  69. 37
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Network.kt
  70. 21
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Clickable.kt
  71. 88
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Draggable.kt
  72. 43
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt
  73. 47
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Scalable.kt
  74. 27
      experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/SplashUI.kt
  75. 53
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/R.kt
  76. 7
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/core/BitmapFilter.kt
  77. 362
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/DesktopContentState.kt
  78. 130
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/ImageHandler.kt
  79. 12
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/Picture.kt
  80. 12
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt
  81. 12
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt
  82. 53
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt
  83. 12
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt
  84. 12
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt
  85. 42
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/style/Decoration.kt
  86. 53
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt
  87. 9
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Coroutines.kt
  88. 206
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.kt
  89. 40
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/AppUI.kt
  90. 207
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/FullscreenImage.kt
  91. 250
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/MainScreen.kt
  92. 59
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Toast.kt
  93. 38
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Tooltip.kt
  94. 67
      experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Zoomable.kt
  95. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/back.png
  96. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_off.png
  97. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_on.png
  98. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/dots.png
  99. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/empty.png
  100. BIN
      experimental/examples/imageviewer/common/src/desktopMain/resources/images/filter_unknown.png
  101. Some files were not shown because too many files have changed in this diff Show More

15
experimental/examples/imageviewer/.gitignore vendored

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build/
/captures
.externalNativeBuild
.cxx

21
experimental/examples/imageviewer/.run/desktop.run.xml

@ -0,0 +1,21 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="desktop" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/desktop" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="run" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
<method v="2" />
</configuration>
</component>

18
experimental/examples/imageviewer/README.md

@ -0,0 +1,18 @@
An example of image gallery for remote server image viewing, based on Jetpack Compose UI library (desktop and android).
### Running desktop application
* To run, launch command: `./gradlew :desktop:run`
* Or choose **desktop** configuration in IDE and run it.
![desktop-run-configuration.png](screenshots/desktop-run-configuration.png)
### Building native desktop distribution
```
./gradlew :desktop:packageDistributionForCurrentOS
# outputs are written to desktop/build/compose/binaries
```
### Running Android application
Open project in IntelliJ IDEA or Android Studio and run "android" configuration.
![Desktop](screenshots/imageviewer.png)

26
experimental/examples/imageviewer/android/build.gradle.kts

@ -0,0 +1,26 @@
plugins {
id("com.android.application")
kotlin("android")
id("org.jetbrains.compose")
}
android {
compileSdk = 32
defaultConfig {
minSdk = 26
targetSdk = 32
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
implementation(project(":common"))
implementation("androidx.activity:activity-compose:1.5.0")
}

28
experimental/examples/imageviewer/android/src/main/AndroidManifest.xml

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="example.imageviewer">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_imageviewer"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_imageviewer_round"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:exported="true"
android:name="example.imageviewer.MainActivity"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

23
experimental/examples/imageviewer/android/src/main/java/example/imageviewer/MainActivity.kt

@ -0,0 +1,23 @@
package example.imageviewer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent
import example.imageviewer.view.AppUI
import example.imageviewer.model.ContentState
import example.imageviewer.model.ImageRepository
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val content = ContentState.applyContent(
this@MainActivity,
"https://raw.githubusercontent.com/JetBrains/compose-jb/master/artwork/imageviewerrepo/fetching.list"
)
setContent {
AppUI(content)
}
}
}

18
experimental/examples/imageviewer/build.gradle.kts

@ -0,0 +1,18 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
kotlin("jvm") apply false
kotlin("multiplatform") apply false
kotlin("android") apply false
id("com.android.application") apply false
id("com.android.library") apply false
id("org.jetbrains.compose") apply false
}
subprojects {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}

54
experimental/examples/imageviewer/common/build.gradle.kts

@ -0,0 +1,54 @@
plugins {
id("com.android.library")
kotlin("multiplatform")
id("org.jetbrains.compose")
}
kotlin {
android()
jvm("desktop")
sourceSets {
named("commonMain") {
dependencies {
api(compose.runtime)
api(compose.foundation)
api(compose.material)
implementation("io.ktor:ktor-client-core:1.4.1")
}
}
named("androidMain") {
dependencies {
api("androidx.appcompat:appcompat:1.5.1")
api("androidx.core:core-ktx:1.8.0")
implementation("io.ktor:ktor-client-cio:1.4.1")
}
}
named("desktopMain") {
dependencies {
api(compose.desktop.common)
implementation("io.ktor:ktor-client-cio:1.4.1")
}
}
}
}
android {
compileSdk = 32
defaultConfig {
minSdk = 26
targetSdk = 32
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
sourceSets {
named("main") {
manifest.srcFile("src/androidMain/AndroidManifest.xml")
res.srcDirs("src/androidMain/res")
}
}
}

2
experimental/examples/imageviewer/common/src/androidMain/AndroidManifest.xml

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="example.imageviewer.common"/>

7
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/core/BitmapFilter.kt

@ -0,0 +1,7 @@
package example.imageviewer.core
import android.graphics.Bitmap
interface BitmapFilter {
fun apply(bitmap: Bitmap) : Bitmap
}

383
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/AndroidContentState.kt

@ -0,0 +1,383 @@
package example.imageviewer.model
import android.content.Context
import android.graphics.*
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import example.imageviewer.common.R
import example.imageviewer.core.FilterType
import example.imageviewer.model.filtration.FiltersManager
import example.imageviewer.utils.clearCache
import example.imageviewer.utils.isInternetAvailable
import example.imageviewer.view.showPopUpMessage
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
object ContentState {
private lateinit var context: Context
private lateinit var repository: ImageRepository
private lateinit var uriRepository: String
fun applyContent(context: Context, uriRepository: String): ContentState {
if (this::uriRepository.isInitialized && this.uriRepository == uriRepository) {
return this
}
this.context = context
this.uriRepository = uriRepository
repository = ImageRepository(uriRepository)
appliedFilters = FiltersManager(context)
isContentReady.value = false
initData()
return this
}
private val executor: ExecutorService by lazy { Executors.newFixedThreadPool(2) }
private val handler: Handler by lazy { Handler(Looper.getMainLooper()) }
fun getContext(): Context {
return context
}
fun getOrientation(): Int {
return context.resources.configuration.orientation
}
private val isAppReady = mutableStateOf(false)
fun isAppReady(): Boolean {
return isAppReady.value
}
private val isContentReady = mutableStateOf(false)
fun isContentReady(): Boolean {
return isContentReady.value
}
fun getString(id: Int): String {
return context.getString(id)
}
// drawable content
private val mainImage = mutableStateOf(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))
private val currentImageIndex = mutableStateOf(0)
private val miniatures = Miniatures()
fun getMiniatures(): List<Picture> {
return miniatures.getMiniatures()
}
fun getSelectedImage(): Bitmap {
return mainImage.value
}
fun getSelectedImageName(): String {
return MainImageWrapper.getName()
}
// filters managing
private lateinit var appliedFilters: FiltersManager
private val filterUIState: MutableMap<FilterType, MutableState<Boolean>> = LinkedHashMap()
private fun toggleFilterState(filter: FilterType) {
if (!filterUIState.containsKey(filter)) {
filterUIState[filter] = mutableStateOf(true)
} else {
val value = filterUIState[filter]!!.value
filterUIState[filter]!!.value = !value
}
}
fun toggleFilter(filter: FilterType) {
if (containsFilter(filter)) {
removeFilter(filter)
} else {
addFilter(filter)
}
toggleFilterState(filter)
var bitmap = MainImageWrapper.origin
if (bitmap != null) {
bitmap = appliedFilters.applyFilters(bitmap)
MainImageWrapper.setImage(bitmap)
mainImage.value = bitmap
}
}
private fun addFilter(filter: FilterType) {
appliedFilters.add(filter)
MainImageWrapper.addFilter(filter)
}
private fun removeFilter(filter: FilterType) {
appliedFilters.remove(filter)
MainImageWrapper.removeFilter(filter)
}
private fun containsFilter(type: FilterType): Boolean {
return appliedFilters.contains(type)
}
fun isFilterEnabled(type: FilterType): Boolean {
if (!filterUIState.containsKey(type)) {
filterUIState[type] = mutableStateOf(false)
}
return filterUIState[type]!!.value
}
private fun restoreFilters(): Bitmap {
filterUIState.clear()
appliedFilters.clear()
return MainImageWrapper.restore()
}
fun restoreMainImage() {
mainImage.value = restoreFilters()
}
// application content initialization
private fun initData() {
if (isContentReady.value)
return
val directory = context.cacheDir.absolutePath
executor.execute {
try {
if (isInternetAvailable()) {
val imageList = repository.get()
if (imageList.isEmpty()) {
handler.post {
showPopUpMessage(
getString(R.string.repo_invalid),
context
)
onContentReady()
}
return@execute
}
val pictureList = loadImages(directory, imageList)
if (pictureList.isEmpty()) {
handler.post {
showPopUpMessage(
getString(R.string.repo_empty),
context
)
onContentReady()
}
} else {
val picture = loadFullImage(imageList[0])
handler.post {
miniatures.setMiniatures(pictureList)
if (isMainImageEmpty()) {
wrapPictureIntoMainImage(picture)
} else {
appliedFilters.add(MainImageWrapper.getFilters())
mainImage.value = MainImageWrapper.getImage()
currentImageIndex.value = MainImageWrapper.getId()
}
onContentReady()
}
}
} else {
handler.post {
showPopUpMessage(
getString(R.string.no_internet),
context
)
onContentReady()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// preview/fullscreen image managing
fun isMainImageEmpty(): Boolean {
return MainImageWrapper.isEmpty()
}
fun fullscreen(picture: Picture) {
isContentReady.value = false
AppState.screenState(ScreenType.FullscreenImage)
setMainImage(picture)
}
fun setMainImage(picture: Picture) {
if (MainImageWrapper.getId() == picture.id) {
if (!isContentReady())
onContentReady()
return
}
isContentReady.value = false
executor.execute {
if (isInternetAvailable()) {
val fullSizePicture = loadFullImage(picture.source)
fullSizePicture.id = picture.id
handler.post {
wrapPictureIntoMainImage(fullSizePicture)
onContentReady()
}
} else {
handler.post {
showPopUpMessage(
"${getString(R.string.no_internet)}\n${getString(R.string.load_image_unavailable)}",
context
)
wrapPictureIntoMainImage(picture)
}
}
}
}
private fun onContentReady() {
isContentReady.value = true
isAppReady.value = true
}
private fun wrapPictureIntoMainImage(picture: Picture) {
MainImageWrapper.wrapPicture(picture)
MainImageWrapper.saveOrigin()
mainImage.value = picture.image
currentImageIndex.value = picture.id
}
fun swipeNext() {
if (currentImageIndex.value == miniatures.size() - 1) {
showPopUpMessage(
getString(R.string.last_image),
context
)
return
}
restoreFilters()
setMainImage(miniatures.get(++currentImageIndex.value))
}
fun swipePrevious() {
if (currentImageIndex.value == 0) {
showPopUpMessage(
getString(R.string.first_image),
context
)
return
}
restoreFilters()
setMainImage(miniatures.get(--currentImageIndex.value))
}
fun refresh() {
executor.execute {
if (isInternetAvailable()) {
handler.post {
clearCache(context)
MainImageWrapper.clear()
miniatures.clear()
isContentReady.value = false
initData()
}
} else {
handler.post {
showPopUpMessage(
"${getString(R.string.no_internet)}\n${getString(R.string.refresh_unavailable)}",
context
)
}
}
}
}
}
private object MainImageWrapper {
// origin image
var origin: Bitmap? = null
private set
fun saveOrigin() {
origin = copy(picture.value.image)
}
fun restore(): Bitmap {
if (origin != null) {
filtersSet.clear()
picture.value.image = copy(origin!!)
}
return copy(picture.value.image)
}
// picture adapter
private var picture = mutableStateOf(
Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))
)
fun wrapPicture(picture: Picture) {
this.picture.value = picture
}
fun setImage(bitmap: Bitmap) {
picture.value.image = bitmap
}
fun isEmpty(): Boolean {
return (picture.value.name == "")
}
fun clear() {
picture.value = Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))
}
fun getName(): String {
return picture.value.name
}
fun getImage(): Bitmap {
return picture.value.image
}
fun getId(): Int {
return picture.value.id
}
// applied filters
private var filtersSet: MutableSet<FilterType> = LinkedHashSet()
fun addFilter(filter: FilterType) {
filtersSet.add(filter)
}
fun removeFilter(filter: FilterType) {
filtersSet.remove(filter)
}
fun getFilters(): Set<FilterType> {
return filtersSet
}
private fun copy(bitmap: Bitmap): Bitmap {
return bitmap.copy(bitmap.config, false)
}
}

131
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/ImageHandler.kt

@ -0,0 +1,131 @@
package example.imageviewer.model
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import example.imageviewer.utils.cacheImage
import example.imageviewer.utils.cacheImagePostfix
import example.imageviewer.utils.scaleBitmapAspectRatio
import example.imageviewer.utils.toPx
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.io.BufferedReader
import java.lang.Exception
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
fun loadFullImage(source: String): Picture {
try {
val url = URL(source)
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: InputStream = connection.inputStream
val bitmap: Bitmap? = BitmapFactory.decodeStream(input)
if (bitmap != null) {
return Picture(
source = source,
image = bitmap,
name = getNameURL(source),
width = bitmap.width,
height = bitmap.height
)
}
} catch (e: Exception) {
e.printStackTrace()
}
return Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))
}
fun loadImages(cachePath: String, list: List<String>): MutableList<Picture> {
val result: MutableList<Picture> = ArrayList()
for (source in list) {
val name = getNameURL(source)
val path = cachePath + File.separator + name
if (File(path + "info").exists()) {
addCachedMiniature(filePath = path, outList = result)
} else {
addFreshMiniature(source = source, outList = result, path = cachePath)
}
result.last().id = result.size - 1
}
return result
}
private fun addFreshMiniature(
source: String,
outList: MutableList<Picture>,
path: String
) {
try {
val url = URL(source)
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: InputStream = connection.inputStream
val result: Bitmap? = BitmapFactory.decodeStream(input)
if (result != null) {
val picture = Picture(
source,
getNameURL(source),
scaleBitmapAspectRatio(result, 200, 164),
result.width,
result.height
)
outList.add(picture)
cacheImage(path + getNameURL(source), picture)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun addCachedMiniature(
filePath: String,
outList: MutableList<Picture>
) {
try {
val read = BufferedReader(
InputStreamReader(
FileInputStream(filePath + cacheImagePostfix),
StandardCharsets.UTF_8
)
)
val source = read.readLine()
val width = read.readLine().toInt()
val height = read.readLine().toInt()
read.close()
val result: Bitmap? = BitmapFactory.decodeFile(filePath)
if (result != null) {
val picture = Picture(
source,
getNameURL(source),
result,
width,
height
)
outList.add(picture)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getNameURL(url: String): String {
return url.substring(url.lastIndexOf('/') + 1, url.length)
}

12
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/Picture.kt

@ -0,0 +1,12 @@
package example.imageviewer.model
import android.graphics.Bitmap
actual data class Picture(
var source: String = "",
var name: String = "",
var image: Bitmap,
var width: Int = 0,
var height: Int = 0,
var id: Int = 0
)

13
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt

@ -0,0 +1,13 @@
package example.imageviewer.model.filtration
import android.content.Context
import android.graphics.Bitmap
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyBlurFilter
class BlurFilter(private val context: Context) : BitmapFilter {
override fun apply(bitmap: Bitmap): Bitmap {
return applyBlurFilter(bitmap, context)
}
}

12
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import android.graphics.Bitmap
import example.imageviewer.core.BitmapFilter
class EmptyFilter : BitmapFilter {
override fun apply(bitmap: Bitmap): Bitmap {
return bitmap
}
}

54
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt

@ -0,0 +1,54 @@
package example.imageviewer.model.filtration
import android.content.Context
import android.graphics.Bitmap
import example.imageviewer.core.BitmapFilter
import example.imageviewer.core.FilterType
class FiltersManager(private val context: Context) {
private var filtersMap: MutableMap<FilterType, BitmapFilter> = LinkedHashMap()
fun clear() {
filtersMap = LinkedHashMap()
}
fun add(filters: Collection<FilterType>) {
for (filter in filters)
add(filter)
}
fun add(filter: FilterType) {
if (!filtersMap.containsKey(filter))
filtersMap[filter] = getFilter(filter, context)
}
fun remove(filter: FilterType) {
filtersMap.remove(filter)
}
fun contains(filter: FilterType): Boolean {
return filtersMap.contains(filter)
}
fun applyFilters(bitmap: Bitmap): Bitmap {
var result: Bitmap = bitmap
for (filter in filtersMap) {
result = filter.value.apply(result)
}
return result
}
}
private fun getFilter(type: FilterType, context: Context): BitmapFilter {
return when (type) {
FilterType.GrayScale -> GrayScaleFilter()
FilterType.Pixel -> PixelFilter()
FilterType.Blur -> BlurFilter(context)
}
}

12
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import android.graphics.Bitmap
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyGrayScaleFilter
class GrayScaleFilter : BitmapFilter {
override fun apply(bitmap: Bitmap) : Bitmap {
return applyGrayScaleFilter(bitmap)
}
}

12
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import android.graphics.Bitmap
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyPixelFilter
class PixelFilter : BitmapFilter {
override fun apply(bitmap: Bitmap): Bitmap {
return applyPixelFilter(bitmap)
}
}

38
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/style/Decoration.kt

@ -0,0 +1,38 @@
package example.imageviewer.style
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import example.imageviewer.common.R
@Composable
fun icEmpty() = painterResource(R.drawable.empty)
@Composable
fun icBack() = painterResource(R.drawable.back)
@Composable
fun icRefresh() = painterResource(R.drawable.refresh)
@Composable
fun icDots() = painterResource(R.drawable.dots)
@Composable
fun icFilterGrayscaleOn() = painterResource(R.drawable.grayscale_on)
@Composable
fun icFilterGrayscaleOff() = painterResource(R.drawable.grayscale_off)
@Composable
fun icFilterPixelOn() = painterResource(R.drawable.pixel_on)
@Composable
fun icFilterPixelOff() = painterResource(R.drawable.pixel_off)
@Composable
fun icFilterBlurOn() = painterResource(R.drawable.blur_on)
@Composable
fun icFilterBlurOff() = painterResource(R.drawable.blur_off)
@Composable
fun icFilterUnknown() = painterResource(R.drawable.filter_unknown)

52
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Caching.kt

@ -0,0 +1,52 @@
package example.imageviewer.utils
import android.content.Context
import android.graphics.*
import example.imageviewer.model.Picture
import java.io.File
import java.io.BufferedWriter
import java.io.OutputStreamWriter
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
val cacheImagePostfix = "info"
fun cacheImage(path: String, picture: Picture) {
try {
FileOutputStream(path).use { out ->
picture.image.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val bw =
BufferedWriter(
OutputStreamWriter(
FileOutputStream(path + cacheImagePostfix), StandardCharsets.UTF_8
)
)
bw.write(picture.source)
bw.write("\r\n${picture.width}")
bw.write("\r\n${picture.height}")
bw.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
fun clearCache(context: Context) {
val directory = File(context.cacheDir.absolutePath)
val files: Array<File>? = directory.listFiles()
if (files != null) {
for (file in files) {
if (file.isDirectory)
continue
file.delete()
}
}
}

9
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Coroutines.kt

@ -0,0 +1,9 @@
package example.imageviewer.utils
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.CoroutineContext
actual fun <T> runBlocking(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T = kotlinx.coroutines.runBlocking(context, block)

195
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.kt

@ -0,0 +1,195 @@
package example.imageviewer.utils
import android.content.Context
import android.content.res.Resources
import android.graphics.*
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.compose.ui.layout.ContentScale
import kotlin.math.pow
import kotlin.math.roundToInt
import example.imageviewer.view.DragHandler
fun scaleBitmapAspectRatio(
bitmap: Bitmap,
width: Int,
height: Int,
filter: Boolean = false
): Bitmap {
val boundW: Float = width.toFloat()
val boundH: Float = height.toFloat()
val ratioX: Float = boundW / bitmap.width
val ratioY: Float = boundH / bitmap.height
val ratio: Float = if (ratioX < ratioY) ratioX else ratioY
val resultH = (bitmap.height * ratio).toInt()
val resultW = (bitmap.width * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, resultW, resultH, filter)
}
fun getDisplayBounds(bitmap: Bitmap): Rect {
val boundW: Float = displayWidth().toFloat()
val boundH: Float = displayHeight().toFloat()
val ratioX: Float = bitmap.width / boundW
val ratioY: Float = bitmap.height / boundH
val ratio: Float = if (ratioX > ratioY) ratioX else ratioY
val resultW = (boundW * ratio)
val resultH = (boundH * ratio)
return Rect(0, 0, resultW.toInt(), resultH.toInt())
}
fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap {
val result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val paint = Paint()
paint.colorFilter = ColorMatrixColorFilter(colorMatrix)
canvas.drawBitmap(result, 0f, 0f, paint)
return result
}
fun applyPixelFilter(bitmap: Bitmap): Bitmap {
var result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val w: Int = bitmap.width
val h: Int = bitmap.height
result = scaleBitmapAspectRatio(result, w / 20, h / 20)
result = scaleBitmapAspectRatio(result, w, h)
return result
}
fun applyBlurFilter(bitmap: Bitmap, context: Context): Bitmap {
val result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val renderScript: RenderScript = RenderScript.create(context)
val tmpIn: Allocation = Allocation.createFromBitmap(renderScript, bitmap)
val tmpOut: Allocation = Allocation.createFromBitmap(renderScript, result)
val theIntrinsic: ScriptIntrinsicBlur =
ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript))
theIntrinsic.setRadius(25f)
theIntrinsic.setInput(tmpIn)
theIntrinsic.forEach(tmpOut)
tmpOut.copyTo(result)
return result
}
fun adjustImageScale(bitmap: Bitmap): ContentScale {
val bitmapRatio = (10 * bitmap.width.toFloat() / bitmap.height).toInt()
val displayRatio = (10 * displayWidth().toFloat() / displayHeight()).toInt()
if (displayRatio > bitmapRatio) {
return ContentScale.FillHeight
}
return ContentScale.FillWidth
}
fun toPx(dp: Int): Int {
return (dp * Resources.getSystem().displayMetrics.density).toInt()
}
fun toDp(px: Int): Int {
return (px / Resources.getSystem().displayMetrics.density).toInt()
}
fun displayWidth(): Int {
return Resources.getSystem().displayMetrics.widthPixels
}
fun displayHeight(): Int {
return Resources.getSystem().displayMetrics.heightPixels
}
fun cropBitmapByScale(bitmap: Bitmap, scale: Float, drag: DragHandler): Bitmap {
val crop = cropBitmapByBounds(
bitmap,
getDisplayBounds(bitmap),
scale,
drag
)
return Bitmap.createBitmap(
bitmap,
crop.left,
crop.top,
crop.right - crop.left,
crop.bottom - crop.top
)
}
fun cropBitmapByBounds(
bitmap: Bitmap,
bounds: Rect,
scaleFactor: Float,
drag: DragHandler
): Rect {
if (scaleFactor <= 1f)
return Rect(0, 0, bitmap.width, bitmap.height)
var scale = scaleFactor.toDouble().pow(1.4)
var boundW = (bounds.width() / scale).roundToInt()
var boundH = (bounds.height() / scale).roundToInt()
scale *= displayWidth() / bounds.width().toDouble()
val offsetX = drag.getAmount().x / scale
val offsetY = drag.getAmount().y / scale
if (boundW > bitmap.width) {
boundW = bitmap.width
}
if (boundH > bitmap.height) {
boundH = bitmap.height
}
val invisibleW = bitmap.width - boundW
var leftOffset = (invisibleW / 2.0 - offsetX).roundToInt().toFloat()
if (leftOffset > invisibleW) {
leftOffset = invisibleW.toFloat()
drag.getAmount().x = -((invisibleW / 2.0) * scale).roundToInt().toFloat()
}
if (leftOffset < 0) {
drag.getAmount().x = ((invisibleW / 2.0) * scale).roundToInt().toFloat()
leftOffset = 0f
}
val invisibleH = bitmap.height - boundH
var topOffset = (invisibleH / 2 - offsetY).roundToInt().toFloat()
if (topOffset > invisibleH) {
topOffset = invisibleH.toFloat()
drag.getAmount().y = -((invisibleH / 2.0) * scale).roundToInt().toFloat()
}
if (topOffset < 0) {
drag.getAmount().y = ((invisibleH / 2.0) * scale).roundToInt().toFloat()
topOffset = 0f
}
return Rect(
leftOffset.toInt(),
topOffset.toInt(),
(leftOffset + boundW).toInt(),
(topOffset + boundH).toInt()
)
}

40
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/AppUI.kt

@ -0,0 +1,40 @@
package example.imageviewer.view
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import example.imageviewer.model.AppState
import example.imageviewer.model.ScreenType
import example.imageviewer.model.ContentState
import example.imageviewer.style.Gray
@Composable
fun AppUI(content: ContentState) {
Surface(
modifier = Modifier.fillMaxSize(),
color = Gray
) {
when (AppState.screenState()) {
ScreenType.MainScreen -> {
MainScreen(content)
}
ScreenType.FullscreenImage -> {
FullscreenImage(content)
}
}
}
}
fun showPopUpMessage(text: String, context: Context) {
Toast.makeText(
context,
text,
Toast.LENGTH_SHORT
).show()
}

197
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/FullscreenImage.kt

@ -0,0 +1,197 @@
package example.imageviewer.view
import android.graphics.Bitmap
import android.graphics.Rect
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.runtime.Composable
import androidx.compose.runtime.remember
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.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import example.imageviewer.core.FilterType
import example.imageviewer.model.AppState
import example.imageviewer.model.ContentState
import example.imageviewer.model.ScreenType
import example.imageviewer.style.DarkGray
import example.imageviewer.style.DarkGreen
import example.imageviewer.style.Foreground
import example.imageviewer.style.MiniatureColor
import example.imageviewer.style.Transparent
import example.imageviewer.style.icBack
import example.imageviewer.style.icFilterBlurOff
import example.imageviewer.style.icFilterBlurOn
import example.imageviewer.style.icFilterGrayscaleOff
import example.imageviewer.style.icFilterGrayscaleOn
import example.imageviewer.style.icFilterPixelOff
import example.imageviewer.style.icFilterPixelOn
import example.imageviewer.utils.adjustImageScale
import example.imageviewer.utils.cropBitmapByScale
import example.imageviewer.utils.displayWidth
import example.imageviewer.utils.getDisplayBounds
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
@Composable
fun FullscreenImage(
content: ContentState
) {
Column {
ToolBar(content.getSelectedImageName(), content)
Image(content)
}
if (!content.isContentReady()) {
LoadingScreen()
}
}
@Composable
fun ToolBar(
text: String,
content: ContentState
) {
val scrollState = rememberScrollState()
Surface(color = MiniatureColor, modifier = Modifier.height(44.dp)) {
Row(modifier = Modifier.padding(end = 30.dp)) {
Surface(
color = Transparent,
modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically),
shape = CircleShape
) {
Clickable(
onClick = {
if (content.isContentReady()) {
content.restoreMainImage()
AppState.screenState(ScreenType.MainScreen)
}
}) {
Image(
icBack(),
contentDescription = null,
modifier = Modifier.size(38.dp)
)
}
}
Text(
text,
color = Foreground,
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(scrollState)) {
for (type in FilterType.values()) {
FilterButton(content, type)
}
}
}
}
}
}
@Composable
fun FilterButton(
content: ContentState,
type: FilterType,
modifier: Modifier = Modifier.size(38.dp)
) {
Box(
modifier = Modifier.background(color = Transparent).clip(CircleShape)
) {
Clickable(
onClick = { content.toggleFilter(type) }
) {
Image(
getFilterImage(type = type, content = content),
contentDescription = null,
modifier
)
}
}
Spacer(Modifier.width(20.dp))
}
@Composable
fun getFilterImage(type: FilterType, content: ContentState): Painter {
return when (type) {
FilterType.GrayScale -> if (content.isFilterEnabled(type)) icFilterGrayscaleOn() else icFilterGrayscaleOff()
FilterType.Pixel -> if (content.isFilterEnabled(type)) icFilterPixelOn() else icFilterPixelOff()
FilterType.Blur -> if (content.isFilterEnabled(type)) icFilterBlurOn() else icFilterBlurOff()
}
}
@Composable
fun Image(content: ContentState) {
val drag = remember { DragHandler() }
val scale = remember { ScaleHandler() }
Surface(
color = DarkGray,
modifier = Modifier.fillMaxSize()
) {
Draggable(dragHandler = drag, modifier = Modifier.fillMaxSize()) {
Scalable(onScale = scale, modifier = Modifier.fillMaxSize()) {
val bitmap = imageByGesture(content, scale, drag)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = adjustImageScale(bitmap)
)
}
}
}
}
@Composable
fun imageByGesture(
content: ContentState,
scale: ScaleHandler,
drag: DragHandler
): Bitmap {
val bitmap = cropBitmapByScale(content.getSelectedImage(), scale.factor.value, drag)
if (scale.factor.value > 1f)
return bitmap
if (abs(drag.getDistance().x) > displayWidth() / 10) {
if (drag.getDistance().x < 0) {
content.swipeNext()
} else {
content.swipePrevious()
}
drag.cancel()
}
return bitmap
}

218
experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/MainScreen.kt

@ -0,0 +1,218 @@
package example.imageviewer.view
import android.content.res.Configuration
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import example.imageviewer.common.R
import example.imageviewer.model.AppState
import example.imageviewer.model.ContentState
import example.imageviewer.model.Picture
import example.imageviewer.model.ScreenType
import example.imageviewer.style.DarkGray
import example.imageviewer.style.DarkGreen
import example.imageviewer.style.Foreground
import example.imageviewer.style.LightGray
import example.imageviewer.style.MiniatureColor
import example.imageviewer.style.Transparent
import example.imageviewer.style.icDots
import example.imageviewer.style.icEmpty
import example.imageviewer.style.icRefresh
@Composable
fun MainScreen(content: ContentState) {
Column {
TopContent(content)
ScrollableArea(content)
}
if (!content.isContentReady()) {
LoadingScreen(content.getString(R.string.loading))
}
}
@Composable
fun TopContent(content: ContentState) {
TitleBar(text = content.getString(R.string.app_name), content = content)
if (content.getOrientation() == Configuration.ORIENTATION_PORTRAIT) {
PreviewImage(content)
Spacer(modifier = Modifier.height(10.dp))
Divider()
}
Spacer(modifier = Modifier.height(5.dp))
}
@Composable
fun TitleBar(text: String, content: ContentState) {
TopAppBar(
backgroundColor = DarkGreen,
title = {
Row(Modifier.height(50.dp)) {
Text(
text,
color = Foreground,
modifier = Modifier.weight(1f).align(Alignment.CenterVertically)
)
Surface(
color = Transparent,
modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically),
shape = CircleShape
) {
Clickable(
onClick = {
if (content.isContentReady()) {
content.refresh()
}
}
) {
Image(
icRefresh(),
contentDescription = null,
modifier = Modifier.size(35.dp)
)
}
}
}
})
}
@Composable
fun PreviewImage(content: ContentState) {
Clickable(onClick = {
AppState.screenState(ScreenType.FullscreenImage)
}) {
Card(
backgroundColor = DarkGray,
modifier = Modifier.height(250.dp),
shape = RectangleShape,
elevation = 1.dp
) {
Image(
if (content.isMainImageEmpty()) {
icEmpty()
} else {
BitmapPainter(content.getSelectedImage().asImageBitmap())
},
contentDescription = null,
modifier = Modifier
.fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp),
contentScale = ContentScale.Fit
)
}
}
}
@Composable
fun Miniature(
picture: Picture,
content: ContentState
) {
Card(
backgroundColor = MiniatureColor,
modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp)
.fillMaxWidth()
.clickable {
content.setMainImage(picture)
},
shape = RectangleShape,
elevation = 2.dp
) {
Row(modifier = Modifier.padding(end = 30.dp)) {
Clickable(
onClick = {
content.fullscreen(picture)
}
) {
Image(
picture.image.asImageBitmap(),
contentDescription = null,
modifier = Modifier.height(70.dp)
.width(90.dp)
.padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp),
contentScale = ContentScale.Crop
)
}
Text(
text = picture.name,
color = Foreground,
modifier = Modifier.weight(1f).align(Alignment.CenterVertically).padding(start = 16.dp),
style = MaterialTheme.typography.body1
)
Clickable(
modifier = Modifier.height(70.dp)
.width(30.dp),
onClick = {
showPopUpMessage(
"${content.getString(R.string.picture)} " +
"${picture.name} \n" +
"${content.getString(R.string.size)} " +
"${picture.width}x${picture.height} " +
"${content.getString(R.string.pixels)}",
content.getContext()
)
}
) {
Image(
icDots(),
contentDescription = null,
modifier = Modifier.height(70.dp)
.width(30.dp)
.padding(start = 1.dp, top = 25.dp, end = 1.dp, bottom = 25.dp),
contentScale = ContentScale.FillHeight
)
}
}
}
}
@Composable
fun ScrollableArea(content: ContentState) {
var index = 1
val scrollState = rememberScrollState()
Column(Modifier.verticalScroll(scrollState)) {
for (picture in content.getMiniatures()) {
Miniature(
picture = picture,
content = content
)
Spacer(modifier = Modifier.height(5.dp))
index++
}
}
}
@Composable
fun Divider() {
Divider(
color = LightGray,
modifier = Modifier.padding(start = 10.dp, end = 10.dp)
)
}

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_off.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_on.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/filter_unknown.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_off.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_on.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_off.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_on.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/refresh.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

5
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer.xml

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_imageviewer_background"/>
<foreground android:drawable="@mipmap/ic_imageviewer_foreground"/>
</adaptive-icon>

5
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer_round.xml

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_imageviewer_background"/>
<foreground android:drawable="@mipmap/ic_imageviewer_foreground"/>
</adaptive-icon>

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

15
experimental/examples/imageviewer/common/src/androidMain/res/values-ru/strings.xml

@ -0,0 +1,15 @@
<?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>
</resources>

14
experimental/examples/imageviewer/common/src/androidMain/res/values/strings.xml

@ -0,0 +1,14 @@
<resources>
<string name="app_name">ImageViewer</string>
<string name="loading">Loading images...</string>
<string name="repo_empty">Repository is empty.</string>
<string name="no_internet">No internet access.</string>
<string name="repo_invalid">List of images in current repository is invalid or empty.</string>
<string name="refresh_unavailable">Cannot refresh images.</string>
<string name="load_image_unavailable">Cannot load full size image.</string>
<string name="last_image">This is last image.</string>
<string name="first_image">This is first image.</string>
<string name="picture">Picture:</string>
<string name="size">Size:</string>
<string name="pixels">pixels.</string>
</resources>

18
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/EventLocker.kt

@ -0,0 +1,18 @@
package example.imageviewer.core
class EventLocker {
private var value: Boolean = false
fun lock() {
value = false
}
fun unlock() {
value = true
}
fun isLocked(): Boolean {
return value
}
}

5
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt

@ -0,0 +1,5 @@
package example.imageviewer.core
enum class FilterType {
GrayScale, Pixel, Blur
}

5
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/Repository.kt

@ -0,0 +1,5 @@
package example.imageviewer.core
interface Repository<T> {
fun get() : T
}

33
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ImageRepository.kt

@ -0,0 +1,33 @@
// READ ME FIRST!
//
// Code in this file is shared between the Android and Desktop JVM targets.
// Kotlin's hierarchical multiplatform projects currently
// don't support sharing code depending on JVM declarations.
//
// You can follow the progress for HMPP JVM & Android intermediate source sets here:
// https://youtrack.jetbrains.com/issue/KT-42466
//
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not
// resolve symbols in this file properly.
//
// Resolution errors in your IDE do not indicate a problem with your setup.
package example.imageviewer.model
import example.imageviewer.core.Repository
import example.imageviewer.utils.ktorHttpClient
import example.imageviewer.utils.runBlocking
import io.ktor.client.request.*
class ImageRepository(
private val httpsURL: String
) : Repository<MutableList<String>> {
override fun get(): MutableList<String> {
return runBlocking {
val content = ktorHttpClient.get<String>(httpsURL)
content.lines().toMutableList()
}
}
}

41
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/Miniatures.kt

@ -0,0 +1,41 @@
// READ ME FIRST!
//
// Code in this file is shared between the Android and Desktop JVM targets.
// Kotlin's hierarchical multiplatform projects currently
// don't support sharing code depending on JVM declarations.
//
// You can follow the progress for HMPP JVM & Android intermediate source sets here:
// https://youtrack.jetbrains.com/issue/KT-42466
//
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not
// resolve symbols in this file properly.
//
// Resolution errors in your IDE do not indicate a problem with your setup.
package example.imageviewer.model
expect class Picture
class Miniatures(
private var list: List<Picture> = emptyList()
) {
fun get(index: Int): Picture {
return list[index]
}
fun getMiniatures(): List<Picture> {
return list.toList()
}
fun setMiniatures(list: List<Picture>) {
this.list = list.toList()
}
fun size(): Int {
return list.size
}
fun clear() {
list = emptyList()
}
}

23
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ScreenType.kt

@ -0,0 +1,23 @@
package example.imageviewer.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
enum class ScreenType {
MainScreen, FullscreenImage
}
object AppState {
private var screen: MutableState<ScreenType>
init {
screen = mutableStateOf(ScreenType.MainScreen)
}
fun screenState() : ScreenType {
return screen.value
}
fun screenState(state: ScreenType) {
screen.value = state
}
}

16
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/style/Palette.kt

@ -0,0 +1,16 @@
package example.imageviewer.style
import androidx.compose.ui.graphics.Color
val DarkGreen = Color(16, 139, 102)
val Gray = Color.DarkGray
val LightGray = Color(100, 100, 100)
val DarkGray = Color(32, 32, 32)
val PreviewImageAreaHoverColor = Color(45, 45, 45)
val ToastBackground = Color(23, 23, 23)
val MiniatureColor = Color(50, 50, 50)
val MiniatureHoverColor = Color(55, 55, 55)
val Foreground = Color(210, 210, 210)
val TranslucentBlack = Color(0, 0, 0, 60)
val TranslucentWhite = Color(255, 255, 255, 20)
val Transparent = Color.Transparent

7
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Coroutines.kt

@ -0,0 +1,7 @@
package example.imageviewer.utils
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
expect fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T

37
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Network.kt

@ -0,0 +1,37 @@
// READ ME FIRST!
//
// Code in this file is shared between the Android and Desktop JVM targets.
// Kotlin's hierarchical multiplatform projects currently
// don't support sharing code depending on JVM declarations.
//
// You can follow the progress for HMPP JVM & Android intermediate source sets here:
// https://youtrack.jetbrains.com/issue/KT-42466
//
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not
// resolve symbols in this file properly.
//
// Resolution errors in your IDE do not indicate a problem with your setup.
package example.imageviewer.utils
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
//import java.net.InetAddress
fun isInternetAvailable(): Boolean {
return runBlocking {
try {
ktorHttpClient.head<String>("http://google.com")
true
} catch (e: Exception) {
println(e.message)
false
}
}
}
val ktorHttpClient = HttpClient {}

21
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Clickable.kt

@ -0,0 +1,21 @@
package example.imageviewer.view
import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Modifier
@Composable
fun Clickable(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
children: @Composable () -> Unit = { }
) {
Box(
modifier = modifier.clickable {
onClick?.invoke()
}
) {
children()
}
}

88
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Draggable.kt

@ -0,0 +1,88 @@
package example.imageviewer.view
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import example.imageviewer.core.EventLocker
import example.imageviewer.style.Transparent
@Composable
fun Draggable(
dragHandler: DragHandler,
modifier: Modifier = Modifier,
onUpdate: (() -> Unit)? = null,
children: @Composable() () -> Unit
) {
Surface(
color = Transparent,
modifier = modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { dragHandler.reset() },
onDragEnd = { dragHandler.reset() },
onDragCancel = { dragHandler.cancel() },
) { change, dragAmount ->
dragHandler.drag(dragAmount)
onUpdate?.invoke()
change.consume()
}
}
) {
children()
}
}
class DragHandler {
private val amount = mutableStateOf(Point(0f, 0f))
private val distance = mutableStateOf(Point(0f, 0f))
private val locker: EventLocker = EventLocker()
fun getAmount(): Point {
return amount.value
}
fun getDistance(): Point {
return distance.value
}
fun reset() {
distance.value = Point(Offset.Zero)
locker.unlock()
}
fun cancel() {
distance.value = Point(Offset.Zero)
locker.lock()
}
fun drag(dragDistance: Offset) {
if (locker.isLocked()) {
val dx = dragDistance.x
val dy = dragDistance.y
distance.value = Point(distance.value.x + dx, distance.value.y + dy)
amount.value = Point(amount.value.x + dx, amount.value.y + dy)
}
}
}
class Point {
var x: Float = 0f
var y: Float = 0f
constructor(x: Float, y: Float) {
this.x = x
this.y = y
}
constructor(point: Offset) {
this.x = point.x
this.y = point.y
}
fun setAttr(x: Float, y: Float) {
this.x = x
this.y = y
}
}

43
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt

@ -0,0 +1,43 @@
package example.imageviewer.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import example.imageviewer.style.DarkGray
import example.imageviewer.style.DarkGreen
import example.imageviewer.style.Foreground
import example.imageviewer.style.TranslucentBlack
@Composable
fun LoadingScreen(text: String = "") {
Box(
modifier = Modifier.fillMaxSize().background(color = TranslucentBlack)
) {
Box(modifier = Modifier.align(Alignment.Center)) {
Surface(color = DarkGray, elevation = 4.dp, shape = CircleShape) {
CircularProgressIndicator(
modifier = Modifier.size(50.dp).padding(3.dp, 3.dp, 4.dp, 4.dp),
color = DarkGreen
)
}
}
Text(
text = text,
modifier = Modifier.align(Alignment.Center).offset(0.dp, 70.dp),
style = MaterialTheme.typography.body1,
color = Foreground
)
}
}

47
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Scalable.kt

@ -0,0 +1,47 @@
package example.imageviewer.view
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import example.imageviewer.style.Transparent
@Composable
fun Scalable(
onScale: ScaleHandler,
modifier: Modifier = Modifier,
children: @Composable() () -> Unit
) {
Surface(
color = Transparent,
modifier = modifier.pointerInput(Unit) {
detectTapGestures(onDoubleTap = { onScale.reset() })
detectTransformGestures { _, _, zoom, _ ->
onScale.onScale(zoom)
}
},
) {
children()
}
}
class ScaleHandler(private val maxFactor: Float = 5f, private val minFactor: Float = 1f) {
val factor = mutableStateOf(1f)
fun reset() {
if (factor.value > minFactor)
factor.value = minFactor
}
fun onScale(scaleFactor: Float): Float {
factor.value += scaleFactor - 1f
if (maxFactor < factor.value) factor.value = maxFactor
if (minFactor > factor.value) factor.value = minFactor
return scaleFactor
}
}

27
experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/SplashUI.kt

@ -0,0 +1,27 @@
package example.imageviewer.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import example.imageviewer.style.DarkGray
@Composable
fun SplashUI() {
Box(Modifier.fillMaxSize().background(DarkGray)) {
Text(
// TODO implement common resources
"Image Viewer",
Modifier.align(Alignment.Center),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 100.sp
)
}
}

53
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/R.kt

@ -0,0 +1,53 @@
package example.imageviewer
object ResString {
val appName: String
val loading: String
val repoEmpty: String
val noInternet: String
val repoInvalid: String
val refreshUnavailable: String
val loadImageUnavailable: String
val lastImage: String
val firstImage: String
val picture: String
val size: String
val pixels: String
val back: String
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 = "Обновить"
} else {
appName = "ImageViewer"
loading = "Loading images..."
repoEmpty = "Repository is empty."
noInternet = "No internet access."
repoInvalid = "List of images in current repository is invalid or empty."
refreshUnavailable = "Cannot refresh images."
loadImageUnavailable = "Cannot load full size image."
lastImage = "This is last image."
firstImage = "This is first image."
picture = "Picture:"
size = "Size:"
pixels = "pixels."
back = "Back"
refresh = "Refresh"
}
}
}

7
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/core/BitmapFilter.kt

@ -0,0 +1,7 @@
package example.imageviewer.core
import java.awt.image.BufferedImage
interface BitmapFilter {
fun apply(bitmap: BufferedImage) : BufferedImage
}

362
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/DesktopContentState.kt

@ -0,0 +1,362 @@
package example.imageviewer.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import example.imageviewer.ResString
import example.imageviewer.core.FilterType
import example.imageviewer.model.filtration.FiltersManager
import example.imageviewer.utils.cacheImagePath
import example.imageviewer.utils.clearCache
import example.imageviewer.utils.isInternetAvailable
import example.imageviewer.view.showPopUpMessage
import example.imageviewer.view.DragHandler
import example.imageviewer.view.ScaleHandler
import example.imageviewer.utils.cropBitmapByScale
import example.imageviewer.utils.toByteArray
import java.awt.image.BufferedImage
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.skia.Image
object ContentState {
val drag = DragHandler()
val scale = ScaleHandler()
lateinit var windowState: WindowState
private lateinit var repository: ImageRepository
private lateinit var uriRepository: String
val scope = CoroutineScope(Dispatchers.IO)
fun applyContent(state: WindowState, uriRepository: String): ContentState {
windowState = state
if (this::uriRepository.isInitialized && this.uriRepository == uriRepository) {
return this
}
this.uriRepository = uriRepository
repository = ImageRepository(uriRepository)
isContentReady.value = false
initData()
return this
}
private val isAppReady = mutableStateOf(false)
fun isAppReady(): Boolean {
return isAppReady.value
}
private val isContentReady = mutableStateOf(false)
fun isContentReady(): Boolean {
return isContentReady.value
}
// drawable content
private val mainImage = mutableStateOf(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
private val currentImageIndex = mutableStateOf(0)
private val miniatures = Miniatures()
fun getMiniatures(): List<Picture> {
return miniatures.getMiniatures()
}
fun getSelectedImage(): ImageBitmap {
return MainImageWrapper.mainImageAsImageBitmap.value
}
fun getSelectedImageName(): String {
return MainImageWrapper.getName()
}
// filters managing
private val appliedFilters = FiltersManager()
private val filterUIState: MutableMap<FilterType, MutableState<Boolean>> = LinkedHashMap()
private fun toggleFilterState(filter: FilterType) {
if (!filterUIState.containsKey(filter)) {
filterUIState[filter] = mutableStateOf(true)
} else {
val value = filterUIState[filter]!!.value
filterUIState[filter]!!.value = !value
}
}
fun toggleFilter(filter: FilterType) {
if (containsFilter(filter)) {
removeFilter(filter)
} else {
addFilter(filter)
}
toggleFilterState(filter)
var bitmap = MainImageWrapper.origin
if (bitmap != null) {
bitmap = appliedFilters.applyFilters(bitmap)
MainImageWrapper.setImage(bitmap)
mainImage.value = bitmap
updateMainImage()
}
}
private fun addFilter(filter: FilterType) {
appliedFilters.add(filter)
MainImageWrapper.addFilter(filter)
}
private fun removeFilter(filter: FilterType) {
appliedFilters.remove(filter)
MainImageWrapper.removeFilter(filter)
}
private fun containsFilter(type: FilterType): Boolean {
return appliedFilters.contains(type)
}
fun isFilterEnabled(type: FilterType): Boolean {
if (!filterUIState.containsKey(type)) {
filterUIState[type] = mutableStateOf(false)
}
return filterUIState[type]!!.value
}
private fun restoreFilters(): BufferedImage {
filterUIState.clear()
appliedFilters.clear()
return MainImageWrapper.restore()
}
fun restoreMainImage() {
mainImage.value = restoreFilters()
}
// application content initialization
private fun initData() {
if (isContentReady.value)
return
val directory = File(cacheImagePath)
if (!directory.exists()) {
directory.mkdir()
}
scope.launch(Dispatchers.IO) {
try {
if (isInternetAvailable()) {
val imageList = repository.get()
if (imageList.isEmpty()) {
showPopUpMessage(
ResString.repoInvalid
)
onContentReady()
} else {
val pictureList = loadImages(cacheImagePath, imageList)
if (pictureList.isEmpty()) {
showPopUpMessage(
ResString.repoEmpty
)
onContentReady()
} else {
val picture = loadFullImage(imageList[0])
miniatures.setMiniatures(pictureList)
if (isMainImageEmpty()) {
wrapPictureIntoMainImage(picture)
} else {
appliedFilters.add(MainImageWrapper.getFilters())
currentImageIndex.value = MainImageWrapper.getId()
}
onContentReady()
}
}
} else {
showPopUpMessage(
ResString.noInternet
)
onContentReady()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// preview/fullscreen image managing
fun isMainImageEmpty(): Boolean {
return MainImageWrapper.isEmpty()
}
fun fullscreen(picture: Picture) {
isContentReady.value = false
AppState.screenState(ScreenType.FullscreenImage)
setMainImage(picture)
}
fun setMainImage(picture: Picture) {
if (MainImageWrapper.getId() == picture.id) {
if (!isContentReady()) {
onContentReady()
}
return
}
isContentReady.value = false
scope.launch(Dispatchers.IO) {
scale.reset()
if (isInternetAvailable()) {
val fullSizePicture = loadFullImage(picture.source)
fullSizePicture.id = picture.id
wrapPictureIntoMainImage(fullSizePicture)
} else {
showPopUpMessage(
"${ResString.noInternet}\n${ResString.loadImageUnavailable}"
)
wrapPictureIntoMainImage(picture)
}
onContentReady()
}
}
private fun onContentReady() {
isContentReady.value = true
isAppReady.value = true
}
private fun wrapPictureIntoMainImage(picture: Picture) {
MainImageWrapper.wrapPicture(picture)
MainImageWrapper.saveOrigin()
mainImage.value = picture.image
currentImageIndex.value = picture.id
updateMainImage()
}
fun updateMainImage() {
MainImageWrapper.mainImageAsImageBitmap.value = Image.makeFromEncoded(
toByteArray(
cropBitmapByScale(
mainImage.value,
windowState.size,
scale.factor.value,
drag
)
)
).toComposeImageBitmap()
}
fun swipeNext() {
if (currentImageIndex.value == miniatures.size() - 1) {
showPopUpMessage(ResString.lastImage)
return
}
restoreFilters()
setMainImage(miniatures.get(++currentImageIndex.value))
}
fun swipePrevious() {
if (currentImageIndex.value == 0) {
showPopUpMessage(ResString.firstImage)
return
}
restoreFilters()
setMainImage(miniatures.get(--currentImageIndex.value))
}
fun refresh() {
scope.launch(Dispatchers.IO) {
if (isInternetAvailable()) {
clearCache()
MainImageWrapper.clear()
miniatures.clear()
isContentReady.value = false
initData()
} else {
showPopUpMessage(
"${ResString.noInternet}\n${ResString.refreshUnavailable}"
)
}
}
}
}
private object MainImageWrapper {
// origin image
var origin: BufferedImage? = null
private set
fun saveOrigin() {
origin = copy(picture.value.image)
}
fun restore(): BufferedImage {
if (origin != null) {
picture.value.image = copy(origin!!)
filtersSet.clear()
}
return copy(picture.value.image)
}
var mainImageAsImageBitmap = mutableStateOf(ImageBitmap(1, 1))
// picture adapter
private var picture = mutableStateOf(
Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
)
fun wrapPicture(picture: Picture) {
this.picture.value = picture
}
fun setImage(bitmap: BufferedImage) {
picture.value.image = bitmap
}
fun isEmpty(): Boolean {
return (picture.value.name == "")
}
fun clear() {
picture.value = Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
}
fun getName(): String {
return picture.value.name
}
fun getImage(): BufferedImage {
return picture.value.image
}
fun getId(): Int {
return picture.value.id
}
// applied filters
private var filtersSet: MutableSet<FilterType> = LinkedHashSet()
fun addFilter(filter: FilterType) {
filtersSet.add(filter)
}
fun removeFilter(filter: FilterType) {
filtersSet.remove(filter)
}
fun getFilters(): Set<FilterType> {
return filtersSet
}
private fun copy(bitmap: BufferedImage) : BufferedImage {
val result = BufferedImage(bitmap.width, bitmap.height, bitmap.type)
val graphics = result.createGraphics()
graphics.drawImage(bitmap, 0, 0, result.width, result.height, null)
return result
}
}

130
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/ImageHandler.kt

@ -0,0 +1,130 @@
package example.imageviewer.model
import java.awt.image.BufferedImage
import example.imageviewer.utils.cacheImage
import example.imageviewer.utils.cacheImagePostfix
import example.imageviewer.utils.scaleBitmapAspectRatio
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.io.BufferedReader
import javax.imageio.ImageIO
import java.lang.Exception
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
fun loadFullImage(source: String): Picture {
try {
val url = URL(source)
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: InputStream = connection.inputStream
val bitmap: BufferedImage? = ImageIO.read(input)
if (bitmap != null) {
return Picture(
source = source,
image = bitmap,
name = getNameURL(source),
width = bitmap.width,
height = bitmap.height
)
}
} catch (e: Exception) {
e.printStackTrace()
}
return Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
}
fun loadImages(cachePath: String, list: List<String>): MutableList<Picture> {
val result: MutableList<Picture> = ArrayList()
for (source in list) {
val name = getNameURL(source)
val path = cachePath + File.separator + name
if (File(path + "info").exists()) {
addCachedMiniature(filePath = path, outList = result)
} else {
addFreshMiniature(source = source, outList = result, path = cachePath)
}
result.last().id = result.size - 1
}
return result
}
private fun addFreshMiniature(
source: String,
outList: MutableList<Picture>,
path: String
) {
try {
val url = URL(source)
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: InputStream = connection.inputStream
val result: BufferedImage? = ImageIO.read(input)
if (result != null) {
val picture = Picture(
source,
getNameURL(source),
scaleBitmapAspectRatio(result, 200, 164),
result.width,
result.height
)
outList.add(picture)
cacheImage(path + getNameURL(source), picture)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun addCachedMiniature(
filePath: String,
outList: MutableList<Picture>
) {
try {
val read = BufferedReader(
InputStreamReader(
FileInputStream(filePath + cacheImagePostfix),
StandardCharsets.UTF_8
)
)
val source = read.readLine()
val width = read.readLine().toInt()
val height = read.readLine().toInt()
read.close()
val result: BufferedImage? = ImageIO.read(File(filePath))
if (result != null) {
val picture = Picture(
source,
getNameURL(source),
result,
width,
height
)
outList.add(picture)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getNameURL(url: String): String {
return url.substring(url.lastIndexOf('/') + 1, url.length)
}

12
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/Picture.kt

@ -0,0 +1,12 @@
package example.imageviewer.model
import java.awt.image.BufferedImage
actual data class Picture(
var source: String = "",
var name: String = "",
var image: BufferedImage,
var width: Int = 0,
var height: Int = 0,
var id: Int = 0
)

12
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import java.awt.image.BufferedImage
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyBlurFilter
class BlurFilter : BitmapFilter {
override fun apply(bitmap: BufferedImage): BufferedImage {
return applyBlurFilter(bitmap)
}
}

12
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import java.awt.image.BufferedImage
import example.imageviewer.core.BitmapFilter
class EmptyFilter : BitmapFilter {
override fun apply(bitmap: BufferedImage): BufferedImage {
return bitmap
}
}

53
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt

@ -0,0 +1,53 @@
package example.imageviewer.model.filtration
import java.awt.image.BufferedImage
import example.imageviewer.core.BitmapFilter
import example.imageviewer.core.FilterType
class FiltersManager {
private var filtersMap: MutableMap<FilterType, BitmapFilter> = LinkedHashMap()
fun clear() {
filtersMap = LinkedHashMap()
}
fun add(filters: Collection<FilterType>) {
for (filter in filters)
add(filter)
}
fun add(filter: FilterType) {
if (!filtersMap.containsKey(filter))
filtersMap[filter] = getFilter(filter)
}
fun remove(filter: FilterType) {
filtersMap.remove(filter)
}
fun contains(filter: FilterType): Boolean {
return filtersMap.contains(filter)
}
fun applyFilters(bitmap: BufferedImage): BufferedImage {
var result: BufferedImage = bitmap
for (filter in filtersMap) {
result = filter.value.apply(result)
}
return result
}
}
private fun getFilter(type: FilterType): BitmapFilter {
return when (type) {
FilterType.GrayScale -> GrayScaleFilter()
FilterType.Pixel -> PixelFilter()
FilterType.Blur -> BlurFilter()
}
}

12
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import java.awt.image.BufferedImage
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyGrayScaleFilter
class GrayScaleFilter : BitmapFilter {
override fun apply(bitmap: BufferedImage) : BufferedImage {
return applyGrayScaleFilter(bitmap)
}
}

12
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt

@ -0,0 +1,12 @@
package example.imageviewer.model.filtration
import java.awt.image.BufferedImage
import example.imageviewer.core.BitmapFilter
import example.imageviewer.utils.applyPixelFilter
class PixelFilter : BitmapFilter {
override fun apply(bitmap: BufferedImage): BufferedImage {
return applyPixelFilter(bitmap)
}
}

42
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/style/Decoration.kt

@ -0,0 +1,42 @@
package example.imageviewer.style
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import java.awt.image.BufferedImage
import javax.imageio.ImageIO
@Composable
fun icEmpty() = painterResource("images/empty.png")
@Composable
fun icBack() = painterResource("images/back.png")
@Composable
fun icRefresh() = painterResource("images/refresh.png")
@Composable
fun icDots() = painterResource("images/dots.png")
@Composable
fun icFilterGrayscaleOn() = painterResource("images/grayscale_on.png")
@Composable
fun icFilterGrayscaleOff() = painterResource("images/grayscale_off.png")
@Composable
fun icFilterPixelOn() = painterResource("images/pixel_on.png")
@Composable
fun icFilterPixelOff() = painterResource("images/pixel_off.png")
@Composable
fun icFilterBlurOn() = painterResource("images/blur_on.png")
@Composable
fun icFilterBlurOff() = painterResource("images/blur_off.png")
@Composable
fun icFilterUnknown() = painterResource("images/filter_unknown.png")
@Composable
fun icAppRounded() = painterResource("images/ic_imageviewer_round.png")

53
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt

@ -0,0 +1,53 @@
package example.imageviewer.utils
import java.awt.image.BufferedImage
import example.imageviewer.model.Picture
import javax.imageio.ImageIO
import java.io.File
import java.io.BufferedWriter
import java.io.OutputStreamWriter
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
val cacheImagePostfix = "info"
val cacheImagePath = System.getProperty("user.home")!! +
File.separator + "Pictures/imageviewer" + File.separator
fun cacheImage(path: String, picture: Picture) {
try {
ImageIO.write(picture.image, "png", File(path))
val bw =
BufferedWriter(
OutputStreamWriter(
FileOutputStream(path + cacheImagePostfix),
StandardCharsets.UTF_8
)
)
bw.write(picture.source)
bw.write("\r\n${picture.width}")
bw.write("\r\n${picture.height}")
bw.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
fun clearCache() {
val directory = File(cacheImagePath)
val files: Array<File>? = directory.listFiles()
if (files != null) {
for (file in files) {
if (file.isDirectory)
continue
file.delete()
}
}
}

9
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Coroutines.kt

@ -0,0 +1,9 @@
package example.imageviewer.utils
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.CoroutineContext
actual fun <T> runBlocking(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T = kotlinx.coroutines.runBlocking(context, block)

206
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.kt

@ -0,0 +1,206 @@
package example.imageviewer.utils
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import java.awt.Dimension
import java.awt.Graphics2D
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import javax.imageio.ImageIO
import java.awt.image.BufferedImageOp
import java.awt.image.ConvolveOp
import java.awt.image.Kernel
import kotlin.math.pow
import kotlin.math.roundToInt
import example.imageviewer.view.DragHandler
fun scaleBitmapAspectRatio(
bitmap: BufferedImage,
width: Int,
height: Int
): BufferedImage {
val boundW: Float = width.toFloat()
val boundH: Float = height.toFloat()
val ratioX: Float = boundW / bitmap.width
val ratioY: Float = boundH / bitmap.height
val ratio: Float = if (ratioX < ratioY) ratioX else ratioY
val resultH = (bitmap.height * ratio).toInt()
val resultW = (bitmap.width * ratio).toInt()
val result = BufferedImage(resultW, resultH, BufferedImage.TYPE_INT_ARGB)
val graphics = result.createGraphics()
graphics.drawImage(bitmap, 0, 0, resultW, resultH, null)
graphics.dispose()
return result
}
fun getDisplayBounds(bitmap: BufferedImage, windowSize: DpSize): Rectangle {
val boundW: Float = windowSize.width.value.toFloat()
val boundH: Float = windowSize.height.value.toFloat()
val ratioX: Float = bitmap.width / boundW
val ratioY: Float = bitmap.height / boundH
val ratio: Float = if (ratioX > ratioY) ratioX else ratioY
val resultW = (boundW * ratio)
val resultH = (boundH * ratio)
return Rectangle(0, 0, resultW.toInt(), resultH.toInt())
}
fun applyGrayScaleFilter(bitmap: BufferedImage): BufferedImage {
val result = BufferedImage(
bitmap.getWidth(),
bitmap.getHeight(),
BufferedImage.TYPE_BYTE_GRAY)
val graphics = result.getGraphics()
graphics.drawImage(bitmap, 0, 0, null)
graphics.dispose()
return result
}
fun applyPixelFilter(bitmap: BufferedImage): BufferedImage {
val w: Int = bitmap.width
val h: Int = bitmap.height
var result = scaleBitmapAspectRatio(bitmap, w / 20, h / 20)
result = scaleBitmapAspectRatio(result, w, h)
return result
}
fun applyBlurFilter(bitmap: BufferedImage): BufferedImage {
var result = BufferedImage(bitmap.getWidth(), bitmap.getHeight(), bitmap.type)
val graphics = result.getGraphics()
graphics.drawImage(bitmap, 0, 0, null)
graphics.dispose()
val radius = 11
val size = 11
val weight: Float = 1.0f / (size * size)
val matrix = FloatArray(size * size)
for (i in 0..matrix.size - 1) {
matrix[i] = weight
}
val kernel = Kernel(radius, size, matrix)
val op = ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null)
result = op.filter(result, null)
return result.getSubimage(
radius,
radius,
result.width - radius * 2,
result.height - radius * 2
)
}
fun toByteArray(bitmap: BufferedImage) : ByteArray {
val baos = ByteArrayOutputStream()
ImageIO.write(bitmap, "png", baos)
return baos.toByteArray()
}
fun cropImage(bitmap: BufferedImage, crop: Rectangle) : BufferedImage {
return bitmap.getSubimage(crop.x, crop.y, crop.width, crop.height)
}
fun cropBitmapByScale(
bitmap: BufferedImage,
size: DpSize,
scale: Float,
drag: DragHandler
): BufferedImage {
val crop = cropBitmapByBounds(
bitmap,
getDisplayBounds(bitmap, size),
size,
scale,
drag
)
return cropImage(
bitmap,
Rectangle(crop.x, crop.y, crop.width - crop.x, crop.height - crop.y)
)
}
fun cropBitmapByBounds(
bitmap: BufferedImage,
bounds: Rectangle,
size: DpSize,
scaleFactor: Float,
drag: DragHandler
): Rectangle {
if (scaleFactor <= 1f) {
return Rectangle(0, 0, bitmap.width, bitmap.height)
}
var scale = scaleFactor.toDouble().pow(1.4)
var boundW = (bounds.width / scale).roundToInt()
var boundH = (bounds.height / scale).roundToInt()
scale *= size.width.value / bounds.width.toDouble()
val offsetX = drag.getAmount().x / scale
val offsetY = drag.getAmount().y / scale
if (boundW > bitmap.width) {
boundW = bitmap.width
}
if (boundH > bitmap.height) {
boundH = bitmap.height
}
val invisibleW = bitmap.width - boundW
var leftOffset = (invisibleW / 2.0 - offsetX).roundToInt()
if (leftOffset > invisibleW) {
leftOffset = invisibleW
drag.getAmount().x = -((invisibleW / 2.0) * scale).roundToInt().toFloat()
}
if (leftOffset < 0) {
drag.getAmount().x = ((invisibleW / 2.0) * scale).roundToInt().toFloat()
leftOffset = 0
}
val invisibleH = bitmap.height - boundH
var topOffset = (invisibleH / 2 - offsetY).roundToInt()
if (topOffset > invisibleH) {
topOffset = invisibleH
drag.getAmount().y = -((invisibleH / 2.0) * scale).roundToInt().toFloat()
}
if (topOffset < 0) {
drag.getAmount().y = ((invisibleH / 2.0) * scale).roundToInt().toFloat()
topOffset = 0
}
return Rectangle(leftOffset, topOffset, leftOffset + boundW, topOffset + boundH)
}
fun getPreferredWindowSize(desiredWidth: Int, desiredHeight: Int): DpSize {
val screenSize: Dimension = Toolkit.getDefaultToolkit().screenSize
val preferredWidth: Int = (screenSize.width * 0.8f).toInt()
val preferredHeight: Int = (screenSize.height * 0.8f).toInt()
val width: Int = if (desiredWidth < preferredWidth) desiredWidth else preferredWidth
val height: Int = if (desiredHeight < preferredHeight) desiredHeight else preferredHeight
return DpSize(width.dp, height.dp)
}

40
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/AppUI.kt

@ -0,0 +1,40 @@
package example.imageviewer.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import example.imageviewer.model.AppState
import example.imageviewer.model.ScreenType
import example.imageviewer.model.ContentState
import example.imageviewer.style.Gray
private val message: MutableState<String> = mutableStateOf("")
private val state: MutableState<Boolean> = mutableStateOf(false)
@Composable
fun AppUI(content: ContentState) {
Surface(
modifier = Modifier.fillMaxSize(),
color = Gray
) {
when (AppState.screenState()) {
ScreenType.MainScreen -> {
MainScreen(content)
}
ScreenType.FullscreenImage -> {
FullscreenImage(content)
}
}
}
Toast(message.value, state)
}
fun showPopUpMessage(text: String) {
message.value = text
state.value = true
}

207
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/FullscreenImage.kt

@ -0,0 +1,207 @@
package example.imageviewer.view
import androidx.compose.foundation.*
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import example.imageviewer.core.FilterType
import example.imageviewer.model.AppState
import example.imageviewer.model.ContentState
import example.imageviewer.model.ScreenType
import example.imageviewer.ResString
import example.imageviewer.style.DarkGray
import example.imageviewer.style.Foreground
import example.imageviewer.style.MiniatureColor
import example.imageviewer.style.TranslucentBlack
import example.imageviewer.style.Transparent
import example.imageviewer.style.icBack
import example.imageviewer.style.icFilterBlurOff
import example.imageviewer.style.icFilterBlurOn
import example.imageviewer.style.icFilterGrayscaleOff
import example.imageviewer.style.icFilterGrayscaleOn
import example.imageviewer.style.icFilterPixelOff
import example.imageviewer.style.icFilterPixelOn
@Composable
fun FullscreenImage(
content: ContentState
) {
Column {
ToolBar(content.getSelectedImageName(), content)
Image(content)
}
if (!content.isContentReady()) {
LoadingScreen()
}
}
@Composable
fun ToolBar(
text: String,
content: ContentState
) {
val backButtonInteractionSource = remember { MutableInteractionSource() }
val backButtonHover by backButtonInteractionSource.collectIsHoveredAsState()
Surface(
color = MiniatureColor,
modifier = Modifier.height(44.dp)
) {
Row(modifier = Modifier.padding(end = 30.dp)) {
Surface(
color = Transparent,
modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically),
shape = CircleShape
) {
Tooltip(ResString.back) {
Clickable(
modifier = Modifier
.hoverable(backButtonInteractionSource)
.background(color = if (backButtonHover) TranslucentBlack else Transparent),
onClick = {
if (content.isContentReady()) {
content.restoreMainImage()
AppState.screenState(ScreenType.MainScreen)
}
}) {
Image(
icBack(),
contentDescription = null,
modifier = Modifier.size(38.dp)
)
}
}
}
Text(
text,
color = Foreground,
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
) {
val state = rememberScrollState(0)
Row(modifier = Modifier.horizontalScroll(state)) {
Row {
for (type in FilterType.values()) {
FilterButton(content, type)
}
}
}
}
}
}
}
@Composable
fun FilterButton(
content: ContentState,
type: FilterType,
modifier: Modifier = Modifier.size(38.dp)
) {
val interactionSource = remember { MutableInteractionSource() }
val filterButtonHover by interactionSource.collectIsHoveredAsState()
Box(
modifier = Modifier.background(color = Transparent).clip(CircleShape)
) {
Tooltip("$type") {
Clickable(
modifier = Modifier
.hoverable(interactionSource)
.background(color = if (filterButtonHover) TranslucentBlack else Transparent),
onClick = { content.toggleFilter(type)}
) {
Image(
getFilterImage(type = type, content = content),
contentDescription = null,
modifier
)
}
}
}
Spacer(Modifier.width(20.dp))
}
@Composable
fun getFilterImage(type: FilterType, content: ContentState): Painter {
return when (type) {
FilterType.GrayScale -> if (content.isFilterEnabled(type)) icFilterGrayscaleOn() else icFilterGrayscaleOff()
FilterType.Pixel -> if (content.isFilterEnabled(type)) icFilterPixelOn() else icFilterPixelOff()
FilterType.Blur -> if (content.isFilterEnabled(type)) icFilterBlurOn() else icFilterBlurOff()
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Image(content: ContentState) {
val onUpdate = remember { { content.updateMainImage() } }
Surface(
color = DarkGray,
modifier = Modifier.fillMaxSize()
) {
Draggable(
onUpdate = onUpdate,
dragHandler = content.drag,
modifier = Modifier.fillMaxSize()
) {
Zoomable(
onUpdate = onUpdate,
scaleHandler = content.scale,
modifier = Modifier.fillMaxSize()
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
when (it.key) {
Key.DirectionLeft -> {
content.swipePrevious()
}
Key.DirectionRight -> {
content.swipeNext()
}
}
}
false
}
) {
Image(
bitmap = content.getSelectedImage(),
contentDescription = null,
contentScale = ContentScale.Fit
)
}
}
}
}

250
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/MainScreen.kt

@ -0,0 +1,250 @@
package example.imageviewer.view
import androidx.compose.foundation.*
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.fillMaxHeight
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.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import example.imageviewer.ResString
import example.imageviewer.model.AppState
import example.imageviewer.model.ContentState
import example.imageviewer.model.Picture
import example.imageviewer.model.ScreenType
import example.imageviewer.style.DarkGray
import example.imageviewer.style.DarkGreen
import example.imageviewer.style.Foreground
import example.imageviewer.style.LightGray
import example.imageviewer.style.MiniatureColor
import example.imageviewer.style.MiniatureHoverColor
import example.imageviewer.style.TranslucentBlack
import example.imageviewer.style.TranslucentWhite
import example.imageviewer.style.Transparent
import example.imageviewer.style.icDots
import example.imageviewer.style.icEmpty
import example.imageviewer.style.icRefresh
import example.imageviewer.utils.toByteArray
@Composable
fun MainScreen(content: ContentState) {
Column {
TopContent(content)
ScrollableArea(content)
}
if (!content.isContentReady()) {
LoadingScreen(ResString.loading)
}
}
@Composable
fun TopContent(content: ContentState) {
TitleBar(text = ResString.appName, content = content)
PreviewImage(content)
Spacer(modifier = Modifier.height(10.dp))
Divider()
Spacer(modifier = Modifier.height(5.dp))
}
@Composable
fun TitleBar(text: String, content: ContentState) {
val interactionSource = remember { MutableInteractionSource() }
val refreshButtonHover by interactionSource.collectIsHoveredAsState()
TopAppBar(
backgroundColor = DarkGreen,
title = {
Row(Modifier.height(50.dp)) {
Text(
text,
color = Foreground,
modifier = Modifier.weight(1f).align(Alignment.CenterVertically)
)
Surface(
color = Transparent,
modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically),
shape = CircleShape
) {
Tooltip(ResString.refresh) {
Clickable(
modifier = Modifier
.hoverable(interactionSource)
.background(color = if (refreshButtonHover) TranslucentBlack else Transparent),
onClick = {
if (content.isContentReady()) {
content.refresh()
}
}
) {
Image(
icRefresh(),
contentDescription = null,
modifier = Modifier.size(35.dp)
)
}
}
}
}
})
}
@Composable
fun PreviewImage(content: ContentState) {
Clickable(
modifier = Modifier.background(color = DarkGray),
onClick = {
AppState.screenState(ScreenType.FullscreenImage)
}
) {
Card(
backgroundColor = Transparent,
modifier = Modifier.height(250.dp),
shape = RectangleShape,
elevation = 1.dp
) {
Image(
if (content.isMainImageEmpty())
icEmpty()
else
BitmapPainter(content.getSelectedImage()),
contentDescription = null,
modifier = Modifier
.fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp),
contentScale = ContentScale.Fit
)
}
}
}
@Composable
fun Miniature(
picture: Picture,
content: ContentState
) {
val cardHoverInteractionSource = remember { MutableInteractionSource() }
val cardHover by cardHoverInteractionSource.collectIsHoveredAsState()
val infoButtonInteractionSource = remember { MutableInteractionSource() }
val infoButtonHover by infoButtonInteractionSource.collectIsHoveredAsState()
Card(
backgroundColor = if (cardHover) MiniatureHoverColor else MiniatureColor,
modifier = Modifier.padding(start = 10.dp, end = 18.dp).height(70.dp)
.fillMaxWidth()
.hoverable(cardHoverInteractionSource)
.clickable {
content.setMainImage(picture)
},
shape = RectangleShape
) {
Row(modifier = Modifier.padding(end = 30.dp)) {
Clickable(
onClick = {
content.fullscreen(picture)
}
) {
Image(
org.jetbrains.skia.Image.makeFromEncoded(
toByteArray(picture.image)
).toComposeImageBitmap(),
contentDescription = null,
modifier = Modifier.height(70.dp)
.width(90.dp)
.padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp),
contentScale = ContentScale.Crop
)
}
Text(
text = picture.name,
color = Foreground,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(start = 16.dp),
style = MaterialTheme.typography.body1
)
Clickable(
modifier = Modifier.height(70.dp)
.width(30.dp)
.hoverable(infoButtonInteractionSource)
.background(color = if (infoButtonHover) TranslucentWhite else Transparent),
onClick = {
showPopUpMessage(
"${ResString.picture} " +
"${picture.name} \n" +
"${ResString.size} " +
"${picture.width}x${picture.height} " +
"${ResString.pixels}"
)
}
) {
Image(
icDots(),
contentDescription = null,
modifier = Modifier.height(70.dp)
.width(30.dp)
.padding(start = 1.dp, top = 25.dp, end = 1.dp, bottom = 25.dp),
contentScale = ContentScale.FillHeight
)
}
}
}
}
@Composable
fun ScrollableArea(content: ContentState) {
Box(
modifier = Modifier.fillMaxSize()
.padding(end = 8.dp)
) {
val stateVertical = rememberScrollState(0)
Column(modifier = Modifier.verticalScroll(stateVertical)) {
var index = 1
Column {
for (picture in content.getMiniatures()) {
Miniature(
picture = picture,
content = content
)
Spacer(modifier = Modifier.height(5.dp))
index++
}
}
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(stateVertical),
modifier = Modifier.align(Alignment.CenterEnd)
.fillMaxHeight()
)
}
}
@Composable
fun Divider() {
Divider(
color = LightGray,
modifier = Modifier.padding(start = 10.dp, end = 10.dp)
)
}

59
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Toast.kt

@ -0,0 +1,59 @@
package example.imageviewer.view
import androidx.compose.foundation.layout.Box
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import example.imageviewer.style.Foreground
import example.imageviewer.style.ToastBackground
import kotlinx.coroutines.delay
enum class ToastDuration(val value: Int) {
Short(1000), Long(3000)
}
private var isShown: Boolean = false
@Composable
fun Toast(
text: String,
visibility: MutableState<Boolean> = mutableStateOf(false),
duration: ToastDuration = ToastDuration.Long
) {
if (isShown) {
return
}
if (visibility.value) {
isShown = true
Box(
modifier = Modifier.fillMaxSize().padding(bottom = 20.dp),
contentAlignment = Alignment.BottomCenter
) {
Surface(
modifier = Modifier.size(300.dp, 70.dp),
color = ToastBackground,
shape = RoundedCornerShape(4.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text,
color = Foreground
)
}
LaunchedEffect(Unit) {
delay(duration.value.toLong())
isShown = false
visibility.value = false
}
}
}
}
}

38
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Tooltip.kt

@ -0,0 +1,38 @@
package example.imageviewer.view
import androidx.compose.foundation.BoxWithTooltip
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Tooltip(
text: String = "Tooltip",
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
color = Color(210, 210, 210),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = text,
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.caption
)
}
}
) {
content()
}
}

67
experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Zoomable.kt

@ -0,0 +1,67 @@
package example.imageviewer.view
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.ExperimentalComposeUiApi
import example.imageviewer.style.Transparent
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Zoomable(
scaleHandler: ScaleHandler,
modifier: Modifier = Modifier,
onUpdate: (() -> Unit)? = null,
children: @Composable() () -> Unit
) {
val focusRequester = FocusRequester()
Surface(
color = Transparent,
modifier = modifier.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
when (it.key) {
Key.I -> {
scaleHandler.onScale(1.2f)
onUpdate?.invoke()
}
Key.O -> {
scaleHandler.onScale(0.8f)
onUpdate?.invoke()
}
Key.R -> {
scaleHandler.reset()
onUpdate?.invoke()
}
}
}
false
}
.focusRequester(focusRequester)
.focusable()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = { scaleHandler.reset() }) {
focusRequester.requestFocus()
}
}
) {
children()
}
DisposableEffect(Unit) {
focusRequester.requestFocus()
onDispose { }
}
}

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/back.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_off.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_on.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/dots.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/empty.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
experimental/examples/imageviewer/common/src/desktopMain/resources/images/filter_unknown.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save