diff --git a/experimental/examples/imageviewer/.gitignore b/experimental/examples/imageviewer/.gitignore new file mode 100644 index 0000000000..a32b16597b --- /dev/null +++ b/experimental/examples/imageviewer/.gitignore @@ -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 diff --git a/experimental/examples/imageviewer/.run/desktop.run.xml b/experimental/examples/imageviewer/.run/desktop.run.xml new file mode 100755 index 0000000000..d9335c1be5 --- /dev/null +++ b/experimental/examples/imageviewer/.run/desktop.run.xml @@ -0,0 +1,21 @@ + + + + + + + true + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/README.md b/experimental/examples/imageviewer/README.md new file mode 100755 index 0000000000..3d79c52613 --- /dev/null +++ b/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) diff --git a/experimental/examples/imageviewer/android/build.gradle.kts b/experimental/examples/imageviewer/android/build.gradle.kts new file mode 100755 index 0000000000..d5807ca5f3 --- /dev/null +++ b/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") +} diff --git a/experimental/examples/imageviewer/android/src/main/AndroidManifest.xml b/experimental/examples/imageviewer/android/src/main/AndroidManifest.xml new file mode 100755 index 0000000000..5b1501c058 --- /dev/null +++ b/experimental/examples/imageviewer/android/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/android/src/main/java/example/imageviewer/MainActivity.kt b/experimental/examples/imageviewer/android/src/main/java/example/imageviewer/MainActivity.kt new file mode 100755 index 0000000000..53bb8c6160 --- /dev/null +++ b/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) + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/build.gradle.kts b/experimental/examples/imageviewer/build.gradle.kts new file mode 100755 index 0000000000..bf95fe23b1 --- /dev/null +++ b/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") + } +} diff --git a/experimental/examples/imageviewer/common/build.gradle.kts b/experimental/examples/imageviewer/common/build.gradle.kts new file mode 100755 index 0000000000..3a67917bbe --- /dev/null +++ b/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") + } + } +} diff --git a/experimental/examples/imageviewer/common/src/androidMain/AndroidManifest.xml b/experimental/examples/imageviewer/common/src/androidMain/AndroidManifest.xml new file mode 100755 index 0000000000..7a302a744c --- /dev/null +++ b/experimental/examples/imageviewer/common/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/core/BitmapFilter.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/core/BitmapFilter.kt new file mode 100755 index 0000000000..bf5d0b8c88 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/AndroidContentState.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/AndroidContentState.kt new file mode 100644 index 0000000000..00d4b026bc --- /dev/null +++ b/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 { + 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> = 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 = LinkedHashSet() + + fun addFilter(filter: FilterType) { + filtersSet.add(filter) + } + + fun removeFilter(filter: FilterType) { + filtersSet.remove(filter) + } + + fun getFilters(): Set { + return filtersSet + } + + private fun copy(bitmap: Bitmap): Bitmap { + return bitmap.copy(bitmap.config, false) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/ImageHandler.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/ImageHandler.kt new file mode 100755 index 0000000000..627b36ac5a --- /dev/null +++ b/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): MutableList { + val result: MutableList = 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, + 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 +) { + 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) +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/Picture.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/Picture.kt new file mode 100755 index 0000000000..50a9f33b0d --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt new file mode 100755 index 0000000000..aea277489c --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt new file mode 100755 index 0000000000..2f31b01069 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt new file mode 100755 index 0000000000..35f16ab315 --- /dev/null +++ b/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 = LinkedHashMap() + + fun clear() { + filtersMap = LinkedHashMap() + } + + fun add(filters: Collection) { + + 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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt new file mode 100755 index 0000000000..5567048d68 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt new file mode 100755 index 0000000000..a269b37f4a --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/style/Decoration.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/style/Decoration.kt new file mode 100755 index 0000000000..77cea5c2be --- /dev/null +++ b/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) diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Caching.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Caching.kt new file mode 100755 index 0000000000..7059938cb8 --- /dev/null +++ b/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? = directory.listFiles() + + if (files != null) { + for (file in files) { + if (file.isDirectory) + continue + + file.delete() + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Coroutines.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/Coroutines.kt new file mode 100644 index 0000000000..ab006ef147 --- /dev/null +++ b/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 runBlocking( + context: CoroutineContext, + block: suspend CoroutineScope.() -> T +): T = kotlinx.coroutines.runBlocking(context, block) \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.kt new file mode 100755 index 0000000000..32e234da08 --- /dev/null +++ b/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() + ) +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/AppUI.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/AppUI.kt new file mode 100755 index 0000000000..dacce3b7c3 --- /dev/null +++ b/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() +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/FullscreenImage.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/FullscreenImage.kt new file mode 100644 index 0000000000..1c0e7d73b4 --- /dev/null +++ b/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 +} diff --git a/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/MainScreen.kt b/experimental/examples/imageviewer/common/src/androidMain/kotlin/example/imageviewer/view/MainScreen.kt new file mode 100755 index 0000000000..5509e28050 --- /dev/null +++ b/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) + ) +} diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png new file mode 100755 index 0000000000..206b8d4678 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_off.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_off.png new file mode 100755 index 0000000000..e632616157 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_off.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_on.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_on.png new file mode 100755 index 0000000000..7f5ad81bd6 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/blur_on.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png new file mode 100755 index 0000000000..4eb0c9f1e4 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png new file mode 100755 index 0000000000..54e9007671 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/filter_unknown.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/filter_unknown.png new file mode 100755 index 0000000000..9193c3f33e Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/filter_unknown.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_off.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_off.png new file mode 100755 index 0000000000..57fbe7891c Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_off.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_on.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_on.png new file mode 100755 index 0000000000..ffe1f6102b Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/grayscale_on.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_off.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_off.png new file mode 100755 index 0000000000..a41ebfe04e Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_off.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_on.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_on.png new file mode 100755 index 0000000000..1482ff8583 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/pixel_on.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/drawable/refresh.png b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/refresh.png new file mode 100755 index 0000000000..3be99c1944 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/drawable/refresh.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer.xml b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer.xml new file mode 100755 index 0000000000..151710feb1 --- /dev/null +++ b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer_round.xml b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer_round.xml new file mode 100755 index 0000000000..151710feb1 --- /dev/null +++ b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-anydpi-v26/ic_imageviewer_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer.png new file mode 100755 index 0000000000..048bc8e40f Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_background.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_background.png new file mode 100755 index 0000000000..c9208944b7 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_background.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_foreground.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_foreground.png new file mode 100755 index 0000000000..2d2fcf2c58 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_foreground.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_round.png new file mode 100755 index 0000000000..38ee5762aa Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-hdpi/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer.png new file mode 100755 index 0000000000..97ee3fe7a5 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_background.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_background.png new file mode 100755 index 0000000000..a52e38227a Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_background.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_foreground.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_foreground.png new file mode 100755 index 0000000000..fcab73e062 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_foreground.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_round.png new file mode 100755 index 0000000000..44e98c60bc Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-mdpi/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer.png new file mode 100755 index 0000000000..371b00a186 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_background.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_background.png new file mode 100755 index 0000000000..1c05cfbd31 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_background.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_foreground.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_foreground.png new file mode 100755 index 0000000000..98565fc895 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_foreground.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_round.png new file mode 100755 index 0000000000..ba87f288ff Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xhdpi/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer.png new file mode 100755 index 0000000000..2facfba33e Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_background.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_background.png new file mode 100755 index 0000000000..98c5f7b447 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_background.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_foreground.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_foreground.png new file mode 100755 index 0000000000..bad1d38f5a Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_foreground.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_round.png new file mode 100755 index 0000000000..9b44b12b16 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxhdpi/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer.png new file mode 100755 index 0000000000..efe8478dd5 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_background.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_background.png new file mode 100755 index 0000000000..0023fa325b Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_background.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_foreground.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_foreground.png new file mode 100755 index 0000000000..422d7185b8 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_foreground.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_round.png new file mode 100755 index 0000000000..1843b0f85b Binary files /dev/null and b/experimental/examples/imageviewer/common/src/androidMain/res/mipmap-xxxhdpi/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/values-ru/strings.xml b/experimental/examples/imageviewer/common/src/androidMain/res/values-ru/strings.xml new file mode 100755 index 0000000000..840f22cdb2 --- /dev/null +++ b/experimental/examples/imageviewer/common/src/androidMain/res/values-ru/strings.xml @@ -0,0 +1,15 @@ + + + ImageViewer + Загружаем изображения... + Репозиторий пуст. + Нет доступа в интернет. + Список изображений в репозитории пуст или имеет неверный формат. + Невозможно обновить изображения. + Невозможно загузить полное изображение. + Это последнее изображение. + Это первое изображение. + Изображение: + Размеры: + пикселей. + \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/androidMain/res/values/strings.xml b/experimental/examples/imageviewer/common/src/androidMain/res/values/strings.xml new file mode 100755 index 0000000000..e515aed2e2 --- /dev/null +++ b/experimental/examples/imageviewer/common/src/androidMain/res/values/strings.xml @@ -0,0 +1,14 @@ + + ImageViewer + Loading images... + Repository is empty. + No internet access. + List of images in current repository is invalid or empty. + Cannot refresh images. + Cannot load full size image. + This is last image. + This is first image. + Picture: + Size: + pixels. + \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/EventLocker.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/EventLocker.kt new file mode 100755 index 0000000000..f31ab9ca61 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt new file mode 100755 index 0000000000..53ad4ee60f --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/Repository.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/Repository.kt new file mode 100755 index 0000000000..df6cd11ff0 --- /dev/null +++ b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/core/Repository.kt @@ -0,0 +1,5 @@ +package example.imageviewer.core + +interface Repository { + fun get() : T +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ImageRepository.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ImageRepository.kt new file mode 100755 index 0000000000..14178fa238 --- /dev/null +++ b/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> { + + override fun get(): MutableList { + return runBlocking { + val content = ktorHttpClient.get(httpsURL) + content.lines().toMutableList() + } + } +} diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/Miniatures.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/Miniatures.kt new file mode 100755 index 0000000000..4daaca3940 --- /dev/null +++ b/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 = emptyList() +) { + fun get(index: Int): Picture { + return list[index] + } + + fun getMiniatures(): List { + return list.toList() + } + + fun setMiniatures(list: List) { + this.list = list.toList() + } + + fun size(): Int { + return list.size + } + + fun clear() { + list = emptyList() + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ScreenType.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/model/ScreenType.kt new file mode 100755 index 0000000000..8e38a79e52 --- /dev/null +++ b/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 + init { + screen = mutableStateOf(ScreenType.MainScreen) + } + + fun screenState() : ScreenType { + return screen.value + } + + fun screenState(state: ScreenType) { + screen.value = state + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/style/Palette.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/style/Palette.kt new file mode 100755 index 0000000000..ca4b822fe4 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Coroutines.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Coroutines.kt new file mode 100644 index 0000000000..d35b09543e --- /dev/null +++ b/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 runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Network.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/utils/Network.kt new file mode 100755 index 0000000000..c2d5a23bfa --- /dev/null +++ b/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("http://google.com") + true + } catch (e: Exception) { + println(e.message) + false + } + } +} + +val ktorHttpClient = HttpClient {} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Clickable.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Clickable.kt new file mode 100755 index 0000000000..8eae0782da --- /dev/null +++ b/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() + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Draggable.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Draggable.kt new file mode 100755 index 0000000000..eabbadc4ef --- /dev/null +++ b/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 + } +} diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/LoadingScreen.kt new file mode 100644 index 0000000000..8a6a4191f6 --- /dev/null +++ b/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 + ) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Scalable.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/Scalable.kt new file mode 100755 index 0000000000..ef9887c4f6 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/SplashUI.kt b/experimental/examples/imageviewer/common/src/commonMain/kotlin/example/imageviewer/view/SplashUI.kt new file mode 100644 index 0000000000..544121d2d2 --- /dev/null +++ b/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 + ) + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/R.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/R.kt new file mode 100755 index 0000000000..ef361ecc84 --- /dev/null +++ b/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" + } + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/core/BitmapFilter.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/core/BitmapFilter.kt new file mode 100755 index 0000000000..b8dcdbae47 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/DesktopContentState.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/DesktopContentState.kt new file mode 100644 index 0000000000..36e0983da8 --- /dev/null +++ b/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 { + 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> = 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 = LinkedHashSet() + + fun addFilter(filter: FilterType) { + filtersSet.add(filter) + } + + fun removeFilter(filter: FilterType) { + filtersSet.remove(filter) + } + + fun getFilters(): Set { + 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 + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/ImageHandler.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/ImageHandler.kt new file mode 100755 index 0000000000..5b02b75527 --- /dev/null +++ b/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): MutableList { + val result: MutableList = 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, + 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 +) { + 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) +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/Picture.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/Picture.kt new file mode 100755 index 0000000000..1113afb440 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt new file mode 100755 index 0000000000..7ae798b3ec --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/EmptyFilter.kt new file mode 100755 index 0000000000..1174489ac8 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/FiltersManager.kt new file mode 100755 index 0000000000..37fc441888 --- /dev/null +++ b/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 = LinkedHashMap() + + fun clear() { + filtersMap = LinkedHashMap() + } + + fun add(filters: Collection) { + + 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() + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt new file mode 100755 index 0000000000..6b10bbf3b0 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt new file mode 100755 index 0000000000..5b3a2cf9c2 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/style/Decoration.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/style/Decoration.kt new file mode 100755 index 0000000000..7c06d90124 --- /dev/null +++ b/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") diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt new file mode 100755 index 0000000000..192289f20b --- /dev/null +++ b/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? = directory.listFiles() + + if (files != null) { + for (file in files) { + if (file.isDirectory) + continue + + file.delete() + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Coroutines.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/Coroutines.kt new file mode 100644 index 0000000000..ab006ef147 --- /dev/null +++ b/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 runBlocking( + context: CoroutineContext, + block: suspend CoroutineScope.() -> T +): T = kotlinx.coroutines.runBlocking(context, block) \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.kt new file mode 100755 index 0000000000..711a629426 --- /dev/null +++ b/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) +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/AppUI.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/AppUI.kt new file mode 100755 index 0000000000..ef002711bf --- /dev/null +++ b/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 = mutableStateOf("") +private val state: MutableState = 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 +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/FullscreenImage.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/FullscreenImage.kt new file mode 100644 index 0000000000..e8e91e4930 --- /dev/null +++ b/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 + ) + } + } + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/MainScreen.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/MainScreen.kt new file mode 100755 index 0000000000..edf1f4b27b --- /dev/null +++ b/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) + ) +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Toast.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Toast.kt new file mode 100755 index 0000000000..d214552818 --- /dev/null +++ b/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 = 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 + } + } + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Tooltip.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Tooltip.kt new file mode 100644 index 0000000000..4cee5684fc --- /dev/null +++ b/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() + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Zoomable.kt b/experimental/examples/imageviewer/common/src/desktopMain/kotlin/example/imageviewer/view/Zoomable.kt new file mode 100644 index 0000000000..e9f6321be0 --- /dev/null +++ b/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 { } + } +} diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/back.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/back.png new file mode 100755 index 0000000000..206b8d4678 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/back.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_off.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_off.png new file mode 100755 index 0000000000..e632616157 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_off.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_on.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_on.png new file mode 100755 index 0000000000..7f5ad81bd6 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/blur_on.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/dots.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/dots.png new file mode 100755 index 0000000000..4eb0c9f1e4 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/dots.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/empty.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/empty.png new file mode 100755 index 0000000000..54e9007671 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/empty.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/filter_unknown.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/filter_unknown.png new file mode 100755 index 0000000000..9193c3f33e Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/filter_unknown.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_off.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_off.png new file mode 100755 index 0000000000..57fbe7891c Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_off.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_on.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_on.png new file mode 100755 index 0000000000..ffe1f6102b Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/grayscale_on.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/ic_imageviewer_round.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/ic_imageviewer_round.png new file mode 100755 index 0000000000..1843b0f85b Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/ic_imageviewer_round.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-linux.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-linux.png new file mode 100755 index 0000000000..604b33eb09 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-linux.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-mac.icns b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-mac.icns new file mode 100644 index 0000000000..7783380ee0 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-mac.icns differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-windows.ico b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-windows.ico new file mode 100644 index 0000000000..31bd09b389 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/icon-windows.ico differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_off.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_off.png new file mode 100755 index 0000000000..a41ebfe04e Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_off.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_on.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_on.png new file mode 100755 index 0000000000..1482ff8583 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/pixel_on.png differ diff --git a/experimental/examples/imageviewer/common/src/desktopMain/resources/images/refresh.png b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/refresh.png new file mode 100755 index 0000000000..3be99c1944 Binary files /dev/null and b/experimental/examples/imageviewer/common/src/desktopMain/resources/images/refresh.png differ diff --git a/experimental/examples/imageviewer/desktop/build.gradle.kts b/experimental/examples/imageviewer/desktop/build.gradle.kts new file mode 100755 index 0000000000..2116a0481a --- /dev/null +++ b/experimental/examples/imageviewer/desktop/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") // kotlin("jvm") doesn't work well in IDEA/AndroidStudio (https://github.com/JetBrains/compose-jb/issues/22) + id("org.jetbrains.compose") +} + +kotlin { + jvm { + withJava() + } + sourceSets { + named("jvmMain") { + dependencies { + implementation(compose.desktop.currentOs) + implementation(project(":common")) + } + } + } +} + +compose.desktop { + application { + mainClass = "example.imageviewer.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "ImageViewer" + packageVersion = "1.0.0" + + val iconsRoot = project.file("../common/src/desktopMain/resources/images") + macOS { + iconFile.set(iconsRoot.resolve("icon-mac.icns")) + } + windows { + iconFile.set(iconsRoot.resolve("icon-windows.ico")) + menuGroup = "Compose Examples" + // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html + upgradeUuid = "18159995-d967-4CD2-8885-77BFA97CFA9F" + } + linux { + iconFile.set(iconsRoot.resolve("icon-linux.png")) + } + } + } +} diff --git a/experimental/examples/imageviewer/desktop/rules.pro b/experimental/examples/imageviewer/desktop/rules.pro new file mode 100644 index 0000000000..a01578e999 --- /dev/null +++ b/experimental/examples/imageviewer/desktop/rules.pro @@ -0,0 +1,12 @@ +# Ktor +-keep class io.ktor.** { *; } +-keepclassmembers class io.ktor.** { volatile ; } +-keep class io.ktor.client.engine.cio.** { *; } +-keep class kotlinx.coroutines.** { *; } +-dontwarn kotlinx.atomicfu.** +-dontwarn io.netty.** +-dontwarn com.typesafe.** +-dontwarn org.slf4j.** + +# Obfuscation breaks coroutines/ktor for some reason +-dontobfuscate diff --git a/experimental/examples/imageviewer/desktop/src/jvmMain/kotlin/example/imageviewer/Main.kt b/experimental/examples/imageviewer/desktop/src/jvmMain/kotlin/example/imageviewer/Main.kt new file mode 100644 index 0000000000..c17682c405 --- /dev/null +++ b/experimental/examples/imageviewer/desktop/src/jvmMain/kotlin/example/imageviewer/Main.kt @@ -0,0 +1,59 @@ +package example.imageviewer + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import example.imageviewer.model.ContentState +import example.imageviewer.style.icAppRounded +import example.imageviewer.utils.getPreferredWindowSize +import example.imageviewer.view.AppUI +import example.imageviewer.view.SplashUI + +fun main() = application { + val state = rememberWindowState() + val content = remember { + ContentState.applyContent( + state, + "https://raw.githubusercontent.com/JetBrains/compose-jb/master/artwork/imageviewerrepo/fetching.list" + ) + } + + val icon = icAppRounded() + + if (content.isAppReady()) { + Window( + onCloseRequest = ::exitApplication, + title = "Image Viewer", + state = WindowState( + position = WindowPosition.Aligned(Alignment.Center), + size = getPreferredWindowSize(800, 1000) + ), + icon = icon + ) { + MaterialTheme { + AppUI(content) + } + } + } else { + Window( + onCloseRequest = ::exitApplication, + title = "Image Viewer", + state = WindowState( + position = WindowPosition.Aligned(Alignment.Center), + size = getPreferredWindowSize(800, 300) + ), + undecorated = true, + icon = icon, + ) { + MaterialTheme { + SplashUI() + } + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/gradle.properties b/experimental/examples/imageviewer/gradle.properties new file mode 100755 index 0000000000..c18fa7d46c --- /dev/null +++ b/experimental/examples/imageviewer/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +kotlin.version=1.7.20 +agp.version=7.1.3 +compose.version=1.2.1 diff --git a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..f3d88b1c2f Binary files /dev/null and b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.jar differ diff --git a/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000000..ae04661ee7 --- /dev/null +++ b/experimental/examples/imageviewer/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/experimental/examples/imageviewer/gradlew b/experimental/examples/imageviewer/gradlew new file mode 100755 index 0000000000..fbd7c51583 --- /dev/null +++ b/experimental/examples/imageviewer/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/experimental/examples/imageviewer/gradlew.bat b/experimental/examples/imageviewer/gradlew.bat new file mode 100755 index 0000000000..5093609d51 --- /dev/null +++ b/experimental/examples/imageviewer/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png b/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png new file mode 100644 index 0000000000..3688407c6f Binary files /dev/null and b/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png differ diff --git a/experimental/examples/imageviewer/screenshots/imageviewer.png b/experimental/examples/imageviewer/screenshots/imageviewer.png new file mode 100755 index 0000000000..55ce953839 Binary files /dev/null and b/experimental/examples/imageviewer/screenshots/imageviewer.png differ diff --git a/experimental/examples/imageviewer/settings.gradle.kts b/experimental/examples/imageviewer/settings.gradle.kts new file mode 100755 index 0000000000..f4ac8d1ff7 --- /dev/null +++ b/experimental/examples/imageviewer/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + google() + } + + plugins { + val kotlinVersion = extra["kotlin.version"] as String + val agpVersion = extra["agp.version"] as String + val composeVersion = extra["compose.version"] as String + + kotlin("jvm").version(kotlinVersion) + kotlin("multiplatform").version(kotlinVersion) + kotlin("android").version(kotlinVersion) + id("com.android.application").version(agpVersion) + id("com.android.library").version(agpVersion) + id("org.jetbrains.compose").version(composeVersion) + } +} + +include(":common", ":android", ":desktop")