@ -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 |
@ -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> |
@ -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) |
@ -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") |
||||||
|
} |
@ -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> |
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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") |
||||||
|
} |
||||||
|
} |
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<manifest package="example.imageviewer.common"/> |
@ -0,0 +1,7 @@ |
|||||||
|
package example.imageviewer.core |
||||||
|
|
||||||
|
import android.graphics.Bitmap |
||||||
|
|
||||||
|
interface BitmapFilter { |
||||||
|
fun apply(bitmap: Bitmap) : Bitmap |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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 |
||||||
|
) |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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) |
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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) |
@ -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() |
||||||
|
) |
||||||
|
} |
@ -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() |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
) |
||||||
|
} |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 8.2 KiB |
@ -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> |
@ -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> |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 8.7 KiB |
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 107 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 44 KiB |
@ -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> |
@ -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> |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
package example.imageviewer.core |
||||||
|
|
||||||
|
enum class FilterType { |
||||||
|
GrayScale, Pixel, Blur |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
package example.imageviewer.core |
||||||
|
|
||||||
|
interface Repository<T> { |
||||||
|
fun get() : T |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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 |
@ -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 |
@ -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 {} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
package example.imageviewer.core |
||||||
|
|
||||||
|
import java.awt.image.BufferedImage |
||||||
|
|
||||||
|
interface BitmapFilter { |
||||||
|
fun apply(bitmap: BufferedImage) : BufferedImage |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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 |
||||||
|
) |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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") |
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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) |
@ -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) |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
) |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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 { } |
||||||
|
} |
||||||
|
} |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 11 KiB |