Copy imageviewer to experimental/examples (#2500)
15
experimental/examples/imageviewer/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
21
experimental/examples/imageviewer/.run/desktop.run.xml
Executable file
@@ -0,0 +1,21 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="desktop" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/desktop" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="run" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" value="" />
|
||||
</ExternalSystemSettings>
|
||||
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
18
experimental/examples/imageviewer/README.md
Executable file
@@ -0,0 +1,18 @@
|
||||
An example of image gallery for remote server image viewing, based on Jetpack Compose UI library (desktop and android).
|
||||
|
||||
### Running desktop application
|
||||
* To run, launch command: `./gradlew :desktop:run`
|
||||
* Or choose **desktop** configuration in IDE and run it.
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
26
experimental/examples/imageviewer/android/build.gradle.kts
Executable file
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 32
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
targetSdk = 32
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("androidx.activity:activity-compose:1.5.0")
|
||||
}
|
||||
28
experimental/examples/imageviewer/android/src/main/AndroidManifest.xml
Executable file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="example.imageviewer">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_imageviewer"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_imageviewer_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name="example.imageviewer.MainActivity"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,23 @@
|
||||
package example.imageviewer
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import example.imageviewer.view.AppUI
|
||||
import example.imageviewer.model.ContentState
|
||||
import example.imageviewer.model.ImageRepository
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val content = ContentState.applyContent(
|
||||
this@MainActivity,
|
||||
"https://raw.githubusercontent.com/JetBrains/compose-jb/master/artwork/imageviewerrepo/fetching.list"
|
||||
)
|
||||
|
||||
setContent {
|
||||
AppUI(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
experimental/examples/imageviewer/build.gradle.kts
Executable file
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
// this is necessary to avoid the plugins to be loaded multiple times
|
||||
// in each subproject's classloader
|
||||
kotlin("jvm") apply false
|
||||
kotlin("multiplatform") apply false
|
||||
kotlin("android") apply false
|
||||
id("com.android.application") apply false
|
||||
id("com.android.library") apply false
|
||||
id("org.jetbrains.compose") apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
}
|
||||
54
experimental/examples/imageviewer/common/build.gradle.kts
Executable file
@@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android()
|
||||
jvm("desktop")
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
api(compose.runtime)
|
||||
api(compose.foundation)
|
||||
api(compose.material)
|
||||
implementation("io.ktor:ktor-client-core:1.4.1")
|
||||
}
|
||||
}
|
||||
named("androidMain") {
|
||||
dependencies {
|
||||
api("androidx.appcompat:appcompat:1.5.1")
|
||||
api("androidx.core:core-ktx:1.8.0")
|
||||
implementation("io.ktor:ktor-client-cio:1.4.1")
|
||||
}
|
||||
}
|
||||
named("desktopMain") {
|
||||
dependencies {
|
||||
api(compose.desktop.common)
|
||||
implementation("io.ktor:ktor-client-cio:1.4.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 32
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
targetSdk = 32
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
named("main") {
|
||||
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
res.srcDirs("src/androidMain/res")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/back.png
Executable file
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/dots.png
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
experimental/examples/imageviewer/common/src/androidMain/res/drawable/empty.png
Executable file
|
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 |