Copy imageviewer to experimental/examples (#2500)

This commit is contained in:
dima.avdeev
2022-11-25 17:33:42 +03:00
committed by GitHub
parent 612fab6099
commit e8786ea73f
120 changed files with 4133 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ImageViewer</string>
<string name="loading">Загружаем изображения...</string>
<string name="repo_empty">Репозиторий пуст.</string>
<string name="no_internet">Нет доступа в интернет.</string>
<string name="repo_invalid">Список изображений в репозитории пуст или имеет неверный формат.</string>
<string name="refresh_unavailable">Невозможно обновить изображения.</string>
<string name="load_image_unavailable">Невозможно загузить полное изображение.</string>
<string name="last_image">Это последнее изображение.</string>
<string name="first_image">Это первое изображение.</string>
<string name="picture">Изображение:</string>
<string name="size">Размеры:</string>
<string name="pixels">пикселей.</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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