@ -0,0 +1,15 @@
|
||||
*.iml |
||||
.gradle |
||||
/local.properties |
||||
/.idea |
||||
/.idea/caches |
||||
/.idea/libraries |
||||
/.idea/modules.xml |
||||
/.idea/workspace.xml |
||||
/.idea/navEditor.xml |
||||
/.idea/assetWizardSettings.xml |
||||
.DS_Store |
||||
build/ |
||||
/captures |
||||
.externalNativeBuild |
||||
.cxx |
@ -0,0 +1,21 @@
|
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="desktop" type="GradleRunConfiguration" factoryName="Gradle"> |
||||
<ExternalSystemSettings> |
||||
<option name="executionName" /> |
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/desktop" /> |
||||
<option name="externalSystemIdString" value="GRADLE" /> |
||||
<option name="scriptParameters" value="" /> |
||||
<option name="taskDescriptions"> |
||||
<list /> |
||||
</option> |
||||
<option name="taskNames"> |
||||
<list> |
||||
<option value="run" /> |
||||
</list> |
||||
</option> |
||||
<option name="vmOptions" value="" /> |
||||
</ExternalSystemSettings> |
||||
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled> |
||||
<method v="2" /> |
||||
</configuration> |
||||
</component> |
@ -0,0 +1,18 @@
|
||||
An example of image gallery for remote server image viewing, based on Jetpack Compose UI library (desktop and android). |
||||
|
||||
### Running desktop application |
||||
* To run, launch command: `./gradlew :desktop:run` |
||||
* Or choose **desktop** configuration in IDE and run it. |
||||
![desktop-run-configuration.png](screenshots/desktop-run-configuration.png) |
||||
|
||||
### Building native desktop distribution |
||||
``` |
||||
./gradlew :desktop:packageDistributionForCurrentOS |
||||
# outputs are written to desktop/build/compose/binaries |
||||
``` |
||||
|
||||
### Running Android application |
||||
|
||||
Open project in IntelliJ IDEA or Android Studio and run "android" configuration. |
||||
|
||||
![Desktop](screenshots/imageviewer.png) |
@ -0,0 +1,26 @@
|
||||
plugins { |
||||
id("com.android.application") |
||||
kotlin("android") |
||||
id("org.jetbrains.compose") |
||||
} |
||||
|
||||
android { |
||||
compileSdk = 32 |
||||
|
||||
defaultConfig { |
||||
minSdk = 26 |
||||
targetSdk = 32 |
||||
versionCode = 1 |
||||
versionName = "1.0" |
||||
} |
||||
|
||||
compileOptions { |
||||
sourceCompatibility = JavaVersion.VERSION_11 |
||||
targetCompatibility = JavaVersion.VERSION_11 |
||||
} |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(project(":common")) |
||||
implementation("androidx.activity:activity-compose:1.5.0") |
||||
} |
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
package="example.imageviewer"> |
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" /> |
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> |
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
||||
|
||||
<application |
||||
android:allowBackup="true" |
||||
android:icon="@mipmap/ic_imageviewer" |
||||
android:label="@string/app_name" |
||||
android:roundIcon="@mipmap/ic_imageviewer_round" |
||||
android:supportsRtl="true" |
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar"> |
||||
<activity |
||||
android:exported="true" |
||||
android:name="example.imageviewer.MainActivity" |
||||
> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MAIN" /> |
||||
<category android:name="android.intent.category.LAUNCHER" /> |
||||
</intent-filter> |
||||
</activity> |
||||
</application> |
||||
|
||||
</manifest> |
@ -0,0 +1,23 @@
|
||||
package example.imageviewer |
||||
|
||||
import android.os.Bundle |
||||
import androidx.appcompat.app.AppCompatActivity |
||||
import androidx.activity.compose.setContent |
||||
import example.imageviewer.view.AppUI |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.model.ImageRepository |
||||
|
||||
class MainActivity : AppCompatActivity() { |
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
val content = ContentState.applyContent( |
||||
this@MainActivity, |
||||
"https://raw.githubusercontent.com/JetBrains/compose-jb/master/artwork/imageviewerrepo/fetching.list" |
||||
) |
||||
|
||||
setContent { |
||||
AppUI(content) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,18 @@
|
||||
plugins { |
||||
// this is necessary to avoid the plugins to be loaded multiple times |
||||
// in each subproject's classloader |
||||
kotlin("jvm") apply false |
||||
kotlin("multiplatform") apply false |
||||
kotlin("android") apply false |
||||
id("com.android.application") apply false |
||||
id("com.android.library") apply false |
||||
id("org.jetbrains.compose") apply false |
||||
} |
||||
|
||||
subprojects { |
||||
repositories { |
||||
google() |
||||
mavenCentral() |
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") |
||||
} |
||||
} |
@ -0,0 +1,54 @@
|
||||
plugins { |
||||
id("com.android.library") |
||||
kotlin("multiplatform") |
||||
id("org.jetbrains.compose") |
||||
} |
||||
|
||||
kotlin { |
||||
android() |
||||
jvm("desktop") |
||||
sourceSets { |
||||
named("commonMain") { |
||||
dependencies { |
||||
api(compose.runtime) |
||||
api(compose.foundation) |
||||
api(compose.material) |
||||
implementation("io.ktor:ktor-client-core:1.4.1") |
||||
} |
||||
} |
||||
named("androidMain") { |
||||
dependencies { |
||||
api("androidx.appcompat:appcompat:1.5.1") |
||||
api("androidx.core:core-ktx:1.8.0") |
||||
implementation("io.ktor:ktor-client-cio:1.4.1") |
||||
} |
||||
} |
||||
named("desktopMain") { |
||||
dependencies { |
||||
api(compose.desktop.common) |
||||
implementation("io.ktor:ktor-client-cio:1.4.1") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
android { |
||||
compileSdk = 32 |
||||
|
||||
defaultConfig { |
||||
minSdk = 26 |
||||
targetSdk = 32 |
||||
} |
||||
|
||||
compileOptions { |
||||
sourceCompatibility = JavaVersion.VERSION_11 |
||||
targetCompatibility = JavaVersion.VERSION_11 |
||||
} |
||||
|
||||
sourceSets { |
||||
named("main") { |
||||
manifest.srcFile("src/androidMain/AndroidManifest.xml") |
||||
res.srcDirs("src/androidMain/res") |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest package="example.imageviewer.common"/> |
@ -0,0 +1,7 @@
|
||||
package example.imageviewer.core |
||||
|
||||
import android.graphics.Bitmap |
||||
|
||||
interface BitmapFilter { |
||||
fun apply(bitmap: Bitmap) : Bitmap |
||||
} |
@ -0,0 +1,383 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import android.content.Context |
||||
import android.graphics.* |
||||
import android.os.Handler |
||||
import android.os.Looper |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import example.imageviewer.common.R |
||||
import example.imageviewer.core.FilterType |
||||
import example.imageviewer.model.filtration.FiltersManager |
||||
import example.imageviewer.utils.clearCache |
||||
import example.imageviewer.utils.isInternetAvailable |
||||
import example.imageviewer.view.showPopUpMessage |
||||
import java.util.concurrent.ExecutorService |
||||
import java.util.concurrent.Executors |
||||
|
||||
|
||||
object ContentState { |
||||
|
||||
private lateinit var context: Context |
||||
private lateinit var repository: ImageRepository |
||||
private lateinit var uriRepository: String |
||||
|
||||
fun applyContent(context: Context, uriRepository: String): ContentState { |
||||
if (this::uriRepository.isInitialized && this.uriRepository == uriRepository) { |
||||
return this |
||||
} |
||||
|
||||
this.context = context |
||||
this.uriRepository = uriRepository |
||||
repository = ImageRepository(uriRepository) |
||||
appliedFilters = FiltersManager(context) |
||||
isContentReady.value = false |
||||
|
||||
initData() |
||||
|
||||
return this |
||||
} |
||||
|
||||
private val executor: ExecutorService by lazy { Executors.newFixedThreadPool(2) } |
||||
|
||||
private val handler: Handler by lazy { Handler(Looper.getMainLooper()) } |
||||
|
||||
fun getContext(): Context { |
||||
return context |
||||
} |
||||
|
||||
fun getOrientation(): Int { |
||||
return context.resources.configuration.orientation |
||||
} |
||||
|
||||
private val isAppReady = mutableStateOf(false) |
||||
fun isAppReady(): Boolean { |
||||
return isAppReady.value |
||||
} |
||||
|
||||
private val isContentReady = mutableStateOf(false) |
||||
fun isContentReady(): Boolean { |
||||
return isContentReady.value |
||||
} |
||||
|
||||
fun getString(id: Int): String { |
||||
return context.getString(id) |
||||
} |
||||
|
||||
// drawable content |
||||
private val mainImage = mutableStateOf(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) |
||||
private val currentImageIndex = mutableStateOf(0) |
||||
private val miniatures = Miniatures() |
||||
|
||||
fun getMiniatures(): List<Picture> { |
||||
return miniatures.getMiniatures() |
||||
} |
||||
|
||||
fun getSelectedImage(): Bitmap { |
||||
return mainImage.value |
||||
} |
||||
|
||||
fun getSelectedImageName(): String { |
||||
return MainImageWrapper.getName() |
||||
} |
||||
|
||||
// filters managing |
||||
private lateinit var appliedFilters: FiltersManager |
||||
private val filterUIState: MutableMap<FilterType, MutableState<Boolean>> = LinkedHashMap() |
||||
|
||||
private fun toggleFilterState(filter: FilterType) { |
||||
|
||||
if (!filterUIState.containsKey(filter)) { |
||||
filterUIState[filter] = mutableStateOf(true) |
||||
} else { |
||||
val value = filterUIState[filter]!!.value |
||||
filterUIState[filter]!!.value = !value |
||||
} |
||||
} |
||||
|
||||
fun toggleFilter(filter: FilterType) { |
||||
|
||||
if (containsFilter(filter)) { |
||||
removeFilter(filter) |
||||
} else { |
||||
addFilter(filter) |
||||
} |
||||
|
||||
toggleFilterState(filter) |
||||
|
||||
var bitmap = MainImageWrapper.origin |
||||
|
||||
if (bitmap != null) { |
||||
bitmap = appliedFilters.applyFilters(bitmap) |
||||
MainImageWrapper.setImage(bitmap) |
||||
mainImage.value = bitmap |
||||
} |
||||
} |
||||
|
||||
private fun addFilter(filter: FilterType) { |
||||
appliedFilters.add(filter) |
||||
MainImageWrapper.addFilter(filter) |
||||
} |
||||
|
||||
private fun removeFilter(filter: FilterType) { |
||||
appliedFilters.remove(filter) |
||||
MainImageWrapper.removeFilter(filter) |
||||
} |
||||
|
||||
private fun containsFilter(type: FilterType): Boolean { |
||||
return appliedFilters.contains(type) |
||||
} |
||||
|
||||
fun isFilterEnabled(type: FilterType): Boolean { |
||||
if (!filterUIState.containsKey(type)) { |
||||
filterUIState[type] = mutableStateOf(false) |
||||
} |
||||
return filterUIState[type]!!.value |
||||
} |
||||
|
||||
private fun restoreFilters(): Bitmap { |
||||
filterUIState.clear() |
||||
appliedFilters.clear() |
||||
return MainImageWrapper.restore() |
||||
} |
||||
|
||||
fun restoreMainImage() { |
||||
mainImage.value = restoreFilters() |
||||
} |
||||
|
||||
// application content initialization |
||||
private fun initData() { |
||||
if (isContentReady.value) |
||||
return |
||||
|
||||
val directory = context.cacheDir.absolutePath |
||||
|
||||
executor.execute { |
||||
try { |
||||
if (isInternetAvailable()) { |
||||
val imageList = repository.get() |
||||
|
||||
if (imageList.isEmpty()) { |
||||
handler.post { |
||||
showPopUpMessage( |
||||
getString(R.string.repo_invalid), |
||||
context |
||||
) |
||||
onContentReady() |
||||
} |
||||
return@execute |
||||
} |
||||
|
||||
val pictureList = loadImages(directory, imageList) |
||||
|
||||
if (pictureList.isEmpty()) { |
||||
handler.post { |
||||
showPopUpMessage( |
||||
getString(R.string.repo_empty), |
||||
context |
||||
) |
||||
onContentReady() |
||||
} |
||||
} else { |
||||
val picture = loadFullImage(imageList[0]) |
||||
|
||||
handler.post { |
||||
miniatures.setMiniatures(pictureList) |
||||
|
||||
if (isMainImageEmpty()) { |
||||
wrapPictureIntoMainImage(picture) |
||||
} else { |
||||
appliedFilters.add(MainImageWrapper.getFilters()) |
||||
mainImage.value = MainImageWrapper.getImage() |
||||
currentImageIndex.value = MainImageWrapper.getId() |
||||
} |
||||
onContentReady() |
||||
} |
||||
} |
||||
} else { |
||||
handler.post { |
||||
showPopUpMessage( |
||||
getString(R.string.no_internet), |
||||
context |
||||
) |
||||
onContentReady() |
||||
} |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
} |
||||
|
||||
// preview/fullscreen image managing |
||||
fun isMainImageEmpty(): Boolean { |
||||
return MainImageWrapper.isEmpty() |
||||
} |
||||
|
||||
fun fullscreen(picture: Picture) { |
||||
isContentReady.value = false |
||||
AppState.screenState(ScreenType.FullscreenImage) |
||||
setMainImage(picture) |
||||
} |
||||
|
||||
fun setMainImage(picture: Picture) { |
||||
if (MainImageWrapper.getId() == picture.id) { |
||||
if (!isContentReady()) |
||||
onContentReady() |
||||
return |
||||
} |
||||
isContentReady.value = false |
||||
|
||||
executor.execute { |
||||
if (isInternetAvailable()) { |
||||
|
||||
val fullSizePicture = loadFullImage(picture.source) |
||||
fullSizePicture.id = picture.id |
||||
|
||||
handler.post { |
||||
wrapPictureIntoMainImage(fullSizePicture) |
||||
onContentReady() |
||||
} |
||||
} else { |
||||
handler.post { |
||||
showPopUpMessage( |
||||
"${getString(R.string.no_internet)}\n${getString(R.string.load_image_unavailable)}", |
||||
context |
||||
) |
||||
wrapPictureIntoMainImage(picture) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun onContentReady() { |
||||
isContentReady.value = true |
||||
isAppReady.value = true |
||||
} |
||||
|
||||
private fun wrapPictureIntoMainImage(picture: Picture) { |
||||
MainImageWrapper.wrapPicture(picture) |
||||
MainImageWrapper.saveOrigin() |
||||
mainImage.value = picture.image |
||||
currentImageIndex.value = picture.id |
||||
} |
||||
|
||||
fun swipeNext() { |
||||
if (currentImageIndex.value == miniatures.size() - 1) { |
||||
showPopUpMessage( |
||||
getString(R.string.last_image), |
||||
context |
||||
) |
||||
return |
||||
} |
||||
|
||||
restoreFilters() |
||||
setMainImage(miniatures.get(++currentImageIndex.value)) |
||||
} |
||||
|
||||
fun swipePrevious() { |
||||
if (currentImageIndex.value == 0) { |
||||
showPopUpMessage( |
||||
getString(R.string.first_image), |
||||
context |
||||
) |
||||
return |
||||
} |
||||
|
||||
restoreFilters() |
||||
setMainImage(miniatures.get(--currentImageIndex.value)) |
||||
} |
||||
|
||||
fun refresh() { |
||||
executor.execute { |
||||
if (isInternetAvailable()) { |
||||
handler.post { |
||||
clearCache(context) |
||||
MainImageWrapper.clear() |
||||
miniatures.clear() |
||||
isContentReady.value = false |
||||
initData() |
||||
} |
||||
} else { |
||||
handler.post { |
||||
showPopUpMessage( |
||||
"${getString(R.string.no_internet)}\n${getString(R.string.refresh_unavailable)}", |
||||
context |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private object MainImageWrapper { |
||||
// origin image |
||||
var origin: Bitmap? = null |
||||
private set |
||||
|
||||
fun saveOrigin() { |
||||
origin = copy(picture.value.image) |
||||
} |
||||
|
||||
fun restore(): Bitmap { |
||||
|
||||
if (origin != null) { |
||||
filtersSet.clear() |
||||
picture.value.image = copy(origin!!) |
||||
} |
||||
|
||||
return copy(picture.value.image) |
||||
} |
||||
|
||||
// picture adapter |
||||
private var picture = mutableStateOf( |
||||
Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) |
||||
) |
||||
|
||||
fun wrapPicture(picture: Picture) { |
||||
this.picture.value = picture |
||||
} |
||||
|
||||
fun setImage(bitmap: Bitmap) { |
||||
picture.value.image = bitmap |
||||
} |
||||
|
||||
fun isEmpty(): Boolean { |
||||
return (picture.value.name == "") |
||||
} |
||||
|
||||
fun clear() { |
||||
picture.value = Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) |
||||
} |
||||
|
||||
fun getName(): String { |
||||
return picture.value.name |
||||
} |
||||
|
||||
fun getImage(): Bitmap { |
||||
return picture.value.image |
||||
} |
||||
|
||||
fun getId(): Int { |
||||
return picture.value.id |
||||
} |
||||
|
||||
// applied filters |
||||
private var filtersSet: MutableSet<FilterType> = LinkedHashSet() |
||||
|
||||
fun addFilter(filter: FilterType) { |
||||
filtersSet.add(filter) |
||||
} |
||||
|
||||
fun removeFilter(filter: FilterType) { |
||||
filtersSet.remove(filter) |
||||
} |
||||
|
||||
fun getFilters(): Set<FilterType> { |
||||
return filtersSet |
||||
} |
||||
|
||||
private fun copy(bitmap: Bitmap): Bitmap { |
||||
return bitmap.copy(bitmap.config, false) |
||||
} |
||||
} |
@ -0,0 +1,131 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import android.graphics.Bitmap |
||||
import android.graphics.BitmapFactory |
||||
import example.imageviewer.utils.cacheImage |
||||
import example.imageviewer.utils.cacheImagePostfix |
||||
import example.imageviewer.utils.scaleBitmapAspectRatio |
||||
import example.imageviewer.utils.toPx |
||||
import java.io.File |
||||
import java.io.FileInputStream |
||||
import java.io.InputStream |
||||
import java.io.InputStreamReader |
||||
import java.io.BufferedReader |
||||
import java.lang.Exception |
||||
import java.net.HttpURLConnection |
||||
import java.net.URL |
||||
import java.nio.charset.StandardCharsets |
||||
|
||||
fun loadFullImage(source: String): Picture { |
||||
try { |
||||
val url = URL(source) |
||||
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection |
||||
connection.connectTimeout = 5000 |
||||
connection.connect() |
||||
|
||||
val input: InputStream = connection.inputStream |
||||
val bitmap: Bitmap? = BitmapFactory.decodeStream(input) |
||||
if (bitmap != null) { |
||||
return Picture( |
||||
source = source, |
||||
image = bitmap, |
||||
name = getNameURL(source), |
||||
width = bitmap.width, |
||||
height = bitmap.height |
||||
) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
|
||||
return Picture(image = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) |
||||
} |
||||
|
||||
fun loadImages(cachePath: String, list: List<String>): MutableList<Picture> { |
||||
val result: MutableList<Picture> = ArrayList() |
||||
|
||||
for (source in list) { |
||||
val name = getNameURL(source) |
||||
val path = cachePath + File.separator + name |
||||
|
||||
if (File(path + "info").exists()) { |
||||
addCachedMiniature(filePath = path, outList = result) |
||||
} else { |
||||
addFreshMiniature(source = source, outList = result, path = cachePath) |
||||
} |
||||
|
||||
result.last().id = result.size - 1 |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
private fun addFreshMiniature( |
||||
source: String, |
||||
outList: MutableList<Picture>, |
||||
path: String |
||||
) { |
||||
try { |
||||
val url = URL(source) |
||||
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection |
||||
connection.connectTimeout = 5000 |
||||
connection.connect() |
||||
|
||||
val input: InputStream = connection.inputStream |
||||
val result: Bitmap? = BitmapFactory.decodeStream(input) |
||||
|
||||
if (result != null) { |
||||
val picture = Picture( |
||||
source, |
||||
getNameURL(source), |
||||
scaleBitmapAspectRatio(result, 200, 164), |
||||
result.width, |
||||
result.height |
||||
) |
||||
|
||||
outList.add(picture) |
||||
cacheImage(path + getNameURL(source), picture) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
private fun addCachedMiniature( |
||||
filePath: String, |
||||
outList: MutableList<Picture> |
||||
) { |
||||
try { |
||||
val read = BufferedReader( |
||||
InputStreamReader( |
||||
FileInputStream(filePath + cacheImagePostfix), |
||||
StandardCharsets.UTF_8 |
||||
) |
||||
) |
||||
|
||||
val source = read.readLine() |
||||
val width = read.readLine().toInt() |
||||
val height = read.readLine().toInt() |
||||
|
||||
read.close() |
||||
|
||||
val result: Bitmap? = BitmapFactory.decodeFile(filePath) |
||||
|
||||
if (result != null) { |
||||
val picture = Picture( |
||||
source, |
||||
getNameURL(source), |
||||
result, |
||||
width, |
||||
height |
||||
) |
||||
outList.add(picture) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
private fun getNameURL(url: String): String { |
||||
return url.substring(url.lastIndexOf('/') + 1, url.length) |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import android.graphics.Bitmap |
||||
|
||||
actual data class Picture( |
||||
var source: String = "", |
||||
var name: String = "", |
||||
var image: Bitmap, |
||||
var width: Int = 0, |
||||
var height: Int = 0, |
||||
var id: Int = 0 |
||||
) |
@ -0,0 +1,13 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import android.content.Context |
||||
import android.graphics.Bitmap |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyBlurFilter |
||||
|
||||
class BlurFilter(private val context: Context) : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: Bitmap): Bitmap { |
||||
return applyBlurFilter(bitmap, context) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
|
||||
import android.graphics.Bitmap |
||||
import example.imageviewer.core.BitmapFilter |
||||
|
||||
class EmptyFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: Bitmap): Bitmap { |
||||
return bitmap |
||||
} |
||||
} |
@ -0,0 +1,54 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import android.content.Context |
||||
import android.graphics.Bitmap |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.core.FilterType |
||||
|
||||
class FiltersManager(private val context: Context) { |
||||
|
||||
private var filtersMap: MutableMap<FilterType, BitmapFilter> = LinkedHashMap() |
||||
|
||||
fun clear() { |
||||
filtersMap = LinkedHashMap() |
||||
} |
||||
|
||||
fun add(filters: Collection<FilterType>) { |
||||
|
||||
for (filter in filters) |
||||
add(filter) |
||||
} |
||||
|
||||
fun add(filter: FilterType) { |
||||
|
||||
if (!filtersMap.containsKey(filter)) |
||||
filtersMap[filter] = getFilter(filter, context) |
||||
} |
||||
|
||||
fun remove(filter: FilterType) { |
||||
filtersMap.remove(filter) |
||||
} |
||||
|
||||
fun contains(filter: FilterType): Boolean { |
||||
return filtersMap.contains(filter) |
||||
} |
||||
|
||||
fun applyFilters(bitmap: Bitmap): Bitmap { |
||||
|
||||
var result: Bitmap = bitmap |
||||
for (filter in filtersMap) { |
||||
result = filter.value.apply(result) |
||||
} |
||||
|
||||
return result |
||||
} |
||||
} |
||||
|
||||
private fun getFilter(type: FilterType, context: Context): BitmapFilter { |
||||
|
||||
return when (type) { |
||||
FilterType.GrayScale -> GrayScaleFilter() |
||||
FilterType.Pixel -> PixelFilter() |
||||
FilterType.Blur -> BlurFilter(context) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import android.graphics.Bitmap |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyGrayScaleFilter |
||||
|
||||
class GrayScaleFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: Bitmap) : Bitmap { |
||||
return applyGrayScaleFilter(bitmap) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import android.graphics.Bitmap |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyPixelFilter |
||||
|
||||
class PixelFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: Bitmap): Bitmap { |
||||
return applyPixelFilter(bitmap) |
||||
} |
||||
} |
@ -0,0 +1,38 @@
|
||||
package example.imageviewer.style |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.res.painterResource |
||||
import example.imageviewer.common.R |
||||
|
||||
@Composable |
||||
fun icEmpty() = painterResource(R.drawable.empty) |
||||
|
||||
@Composable |
||||
fun icBack() = painterResource(R.drawable.back) |
||||
|
||||
@Composable |
||||
fun icRefresh() = painterResource(R.drawable.refresh) |
||||
|
||||
@Composable |
||||
fun icDots() = painterResource(R.drawable.dots) |
||||
|
||||
@Composable |
||||
fun icFilterGrayscaleOn() = painterResource(R.drawable.grayscale_on) |
||||
|
||||
@Composable |
||||
fun icFilterGrayscaleOff() = painterResource(R.drawable.grayscale_off) |
||||
|
||||
@Composable |
||||
fun icFilterPixelOn() = painterResource(R.drawable.pixel_on) |
||||
|
||||
@Composable |
||||
fun icFilterPixelOff() = painterResource(R.drawable.pixel_off) |
||||
|
||||
@Composable |
||||
fun icFilterBlurOn() = painterResource(R.drawable.blur_on) |
||||
|
||||
@Composable |
||||
fun icFilterBlurOff() = painterResource(R.drawable.blur_off) |
||||
|
||||
@Composable |
||||
fun icFilterUnknown() = painterResource(R.drawable.filter_unknown) |
@ -0,0 +1,52 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import android.content.Context |
||||
import android.graphics.* |
||||
import example.imageviewer.model.Picture |
||||
import java.io.File |
||||
import java.io.BufferedWriter |
||||
import java.io.OutputStreamWriter |
||||
import java.io.FileOutputStream |
||||
import java.io.IOException |
||||
import java.nio.charset.StandardCharsets |
||||
|
||||
val cacheImagePostfix = "info" |
||||
|
||||
fun cacheImage(path: String, picture: Picture) { |
||||
try { |
||||
FileOutputStream(path).use { out -> |
||||
picture.image.compress(Bitmap.CompressFormat.PNG, 100, out) |
||||
} |
||||
|
||||
val bw = |
||||
BufferedWriter( |
||||
OutputStreamWriter( |
||||
FileOutputStream(path + cacheImagePostfix), StandardCharsets.UTF_8 |
||||
) |
||||
) |
||||
|
||||
bw.write(picture.source) |
||||
bw.write("\r\n${picture.width}") |
||||
bw.write("\r\n${picture.height}") |
||||
bw.close() |
||||
|
||||
} catch (e: IOException) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
fun clearCache(context: Context) { |
||||
|
||||
val directory = File(context.cacheDir.absolutePath) |
||||
|
||||
val files: Array<File>? = directory.listFiles() |
||||
|
||||
if (files != null) { |
||||
for (file in files) { |
||||
if (file.isDirectory) |
||||
continue |
||||
|
||||
file.delete() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,9 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlin.coroutines.CoroutineContext |
||||
|
||||
actual fun <T> runBlocking( |
||||
context: CoroutineContext, |
||||
block: suspend CoroutineScope.() -> T |
||||
): T = kotlinx.coroutines.runBlocking(context, block) |
@ -0,0 +1,195 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import android.content.Context |
||||
import android.content.res.Resources |
||||
import android.graphics.* |
||||
import android.renderscript.Allocation |
||||
import android.renderscript.Element |
||||
import android.renderscript.RenderScript |
||||
import android.renderscript.ScriptIntrinsicBlur |
||||
import androidx.compose.ui.layout.ContentScale |
||||
import kotlin.math.pow |
||||
import kotlin.math.roundToInt |
||||
import example.imageviewer.view.DragHandler |
||||
|
||||
fun scaleBitmapAspectRatio( |
||||
bitmap: Bitmap, |
||||
width: Int, |
||||
height: Int, |
||||
filter: Boolean = false |
||||
): Bitmap { |
||||
val boundW: Float = width.toFloat() |
||||
val boundH: Float = height.toFloat() |
||||
|
||||
val ratioX: Float = boundW / bitmap.width |
||||
val ratioY: Float = boundH / bitmap.height |
||||
val ratio: Float = if (ratioX < ratioY) ratioX else ratioY |
||||
|
||||
val resultH = (bitmap.height * ratio).toInt() |
||||
val resultW = (bitmap.width * ratio).toInt() |
||||
|
||||
return Bitmap.createScaledBitmap(bitmap, resultW, resultH, filter) |
||||
} |
||||
|
||||
fun getDisplayBounds(bitmap: Bitmap): Rect { |
||||
|
||||
val boundW: Float = displayWidth().toFloat() |
||||
val boundH: Float = displayHeight().toFloat() |
||||
|
||||
val ratioX: Float = bitmap.width / boundW |
||||
val ratioY: Float = bitmap.height / boundH |
||||
val ratio: Float = if (ratioX > ratioY) ratioX else ratioY |
||||
val resultW = (boundW * ratio) |
||||
val resultH = (boundH * ratio) |
||||
|
||||
return Rect(0, 0, resultW.toInt(), resultH.toInt()) |
||||
} |
||||
|
||||
fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { |
||||
|
||||
val result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) |
||||
|
||||
val canvas = Canvas(result) |
||||
|
||||
val colorMatrix = ColorMatrix() |
||||
colorMatrix.setSaturation(0f) |
||||
|
||||
val paint = Paint() |
||||
paint.colorFilter = ColorMatrixColorFilter(colorMatrix) |
||||
|
||||
canvas.drawBitmap(result, 0f, 0f, paint) |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun applyPixelFilter(bitmap: Bitmap): Bitmap { |
||||
|
||||
var result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) |
||||
val w: Int = bitmap.width |
||||
val h: Int = bitmap.height |
||||
result = scaleBitmapAspectRatio(result, w / 20, h / 20) |
||||
result = scaleBitmapAspectRatio(result, w, h) |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun applyBlurFilter(bitmap: Bitmap, context: Context): Bitmap { |
||||
|
||||
val result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) |
||||
|
||||
val renderScript: RenderScript = RenderScript.create(context) |
||||
|
||||
val tmpIn: Allocation = Allocation.createFromBitmap(renderScript, bitmap) |
||||
val tmpOut: Allocation = Allocation.createFromBitmap(renderScript, result) |
||||
|
||||
val theIntrinsic: ScriptIntrinsicBlur = |
||||
ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript)) |
||||
|
||||
theIntrinsic.setRadius(25f) |
||||
theIntrinsic.setInput(tmpIn) |
||||
theIntrinsic.forEach(tmpOut) |
||||
|
||||
tmpOut.copyTo(result) |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun adjustImageScale(bitmap: Bitmap): ContentScale { |
||||
val bitmapRatio = (10 * bitmap.width.toFloat() / bitmap.height).toInt() |
||||
val displayRatio = (10 * displayWidth().toFloat() / displayHeight()).toInt() |
||||
|
||||
if (displayRatio > bitmapRatio) { |
||||
return ContentScale.FillHeight |
||||
} |
||||
return ContentScale.FillWidth |
||||
} |
||||
|
||||
fun toPx(dp: Int): Int { |
||||
return (dp * Resources.getSystem().displayMetrics.density).toInt() |
||||
} |
||||
|
||||
fun toDp(px: Int): Int { |
||||
return (px / Resources.getSystem().displayMetrics.density).toInt() |
||||
} |
||||
|
||||
fun displayWidth(): Int { |
||||
return Resources.getSystem().displayMetrics.widthPixels |
||||
} |
||||
|
||||
fun displayHeight(): Int { |
||||
return Resources.getSystem().displayMetrics.heightPixels |
||||
} |
||||
|
||||
fun cropBitmapByScale(bitmap: Bitmap, scale: Float, drag: DragHandler): Bitmap { |
||||
val crop = cropBitmapByBounds( |
||||
bitmap, |
||||
getDisplayBounds(bitmap), |
||||
scale, |
||||
drag |
||||
) |
||||
return Bitmap.createBitmap( |
||||
bitmap, |
||||
crop.left, |
||||
crop.top, |
||||
crop.right - crop.left, |
||||
crop.bottom - crop.top |
||||
) |
||||
} |
||||
|
||||
fun cropBitmapByBounds( |
||||
bitmap: Bitmap, |
||||
bounds: Rect, |
||||
scaleFactor: Float, |
||||
drag: DragHandler |
||||
): Rect { |
||||
if (scaleFactor <= 1f) |
||||
return Rect(0, 0, bitmap.width, bitmap.height) |
||||
|
||||
var scale = scaleFactor.toDouble().pow(1.4) |
||||
|
||||
var boundW = (bounds.width() / scale).roundToInt() |
||||
var boundH = (bounds.height() / scale).roundToInt() |
||||
|
||||
scale *= displayWidth() / bounds.width().toDouble() |
||||
|
||||
val offsetX = drag.getAmount().x / scale |
||||
val offsetY = drag.getAmount().y / scale |
||||
|
||||
if (boundW > bitmap.width) { |
||||
boundW = bitmap.width |
||||
} |
||||
if (boundH > bitmap.height) { |
||||
boundH = bitmap.height |
||||
} |
||||
|
||||
val invisibleW = bitmap.width - boundW |
||||
var leftOffset = (invisibleW / 2.0 - offsetX).roundToInt().toFloat() |
||||
|
||||
if (leftOffset > invisibleW) { |
||||
leftOffset = invisibleW.toFloat() |
||||
drag.getAmount().x = -((invisibleW / 2.0) * scale).roundToInt().toFloat() |
||||
} |
||||
if (leftOffset < 0) { |
||||
drag.getAmount().x = ((invisibleW / 2.0) * scale).roundToInt().toFloat() |
||||
leftOffset = 0f |
||||
} |
||||
|
||||
val invisibleH = bitmap.height - boundH |
||||
var topOffset = (invisibleH / 2 - offsetY).roundToInt().toFloat() |
||||
|
||||
if (topOffset > invisibleH) { |
||||
topOffset = invisibleH.toFloat() |
||||
drag.getAmount().y = -((invisibleH / 2.0) * scale).roundToInt().toFloat() |
||||
} |
||||
if (topOffset < 0) { |
||||
drag.getAmount().y = ((invisibleH / 2.0) * scale).roundToInt().toFloat() |
||||
topOffset = 0f |
||||
} |
||||
|
||||
return Rect( |
||||
leftOffset.toInt(), |
||||
topOffset.toInt(), |
||||
(leftOffset + boundW).toInt(), |
||||
(topOffset + boundH).toInt() |
||||
) |
||||
} |
@ -0,0 +1,40 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import android.content.Context |
||||
import android.widget.Toast |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.style.Gray |
||||
|
||||
@Composable |
||||
fun AppUI(content: ContentState) { |
||||
|
||||
Surface( |
||||
modifier = Modifier.fillMaxSize(), |
||||
color = Gray |
||||
) { |
||||
when (AppState.screenState()) { |
||||
ScreenType.MainScreen -> { |
||||
MainScreen(content) |
||||
} |
||||
ScreenType.FullscreenImage -> { |
||||
FullscreenImage(content) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun showPopUpMessage(text: String, context: Context) { |
||||
Toast.makeText( |
||||
context, |
||||
text, |
||||
Toast.LENGTH_SHORT |
||||
).show() |
||||
} |
@ -0,0 +1,197 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import android.graphics.Bitmap |
||||
import android.graphics.Rect |
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.horizontalScroll |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.CircularProgressIndicator |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.clip |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
import androidx.compose.ui.graphics.painter.Painter |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.core.FilterType |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.style.DarkGray |
||||
import example.imageviewer.style.DarkGreen |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.MiniatureColor |
||||
import example.imageviewer.style.Transparent |
||||
import example.imageviewer.style.icBack |
||||
import example.imageviewer.style.icFilterBlurOff |
||||
import example.imageviewer.style.icFilterBlurOn |
||||
import example.imageviewer.style.icFilterGrayscaleOff |
||||
import example.imageviewer.style.icFilterGrayscaleOn |
||||
import example.imageviewer.style.icFilterPixelOff |
||||
import example.imageviewer.style.icFilterPixelOn |
||||
import example.imageviewer.utils.adjustImageScale |
||||
import example.imageviewer.utils.cropBitmapByScale |
||||
import example.imageviewer.utils.displayWidth |
||||
import example.imageviewer.utils.getDisplayBounds |
||||
import kotlin.math.abs |
||||
import kotlin.math.pow |
||||
import kotlin.math.roundToInt |
||||
|
||||
@Composable |
||||
fun FullscreenImage( |
||||
content: ContentState |
||||
) { |
||||
Column { |
||||
ToolBar(content.getSelectedImageName(), content) |
||||
Image(content) |
||||
} |
||||
if (!content.isContentReady()) { |
||||
LoadingScreen() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun ToolBar( |
||||
text: String, |
||||
content: ContentState |
||||
) { |
||||
val scrollState = rememberScrollState() |
||||
Surface(color = MiniatureColor, modifier = Modifier.height(44.dp)) { |
||||
Row(modifier = Modifier.padding(end = 30.dp)) { |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
Clickable( |
||||
onClick = { |
||||
if (content.isContentReady()) { |
||||
content.restoreMainImage() |
||||
AppState.screenState(ScreenType.MainScreen) |
||||
} |
||||
}) { |
||||
Image( |
||||
icBack(), |
||||
contentDescription = null, |
||||
modifier = Modifier.size(38.dp) |
||||
) |
||||
} |
||||
} |
||||
Text( |
||||
text, |
||||
color = Foreground, |
||||
maxLines = 1, |
||||
modifier = Modifier.padding(start = 30.dp).weight(1f) |
||||
.align(Alignment.CenterVertically), |
||||
style = MaterialTheme.typography.body1 |
||||
) |
||||
|
||||
Surface( |
||||
color = Color(255, 255, 255, 40), |
||||
modifier = Modifier.size(154.dp, 38.dp) |
||||
.align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
Row(Modifier.horizontalScroll(scrollState)) { |
||||
for (type in FilterType.values()) { |
||||
FilterButton(content, type) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun FilterButton( |
||||
content: ContentState, |
||||
type: FilterType, |
||||
modifier: Modifier = Modifier.size(38.dp) |
||||
) { |
||||
Box( |
||||
modifier = Modifier.background(color = Transparent).clip(CircleShape) |
||||
) { |
||||
Clickable( |
||||
onClick = { content.toggleFilter(type) } |
||||
) { |
||||
Image( |
||||
getFilterImage(type = type, content = content), |
||||
contentDescription = null, |
||||
modifier |
||||
) |
||||
} |
||||
} |
||||
|
||||
Spacer(Modifier.width(20.dp)) |
||||
} |
||||
|
||||
@Composable |
||||
fun getFilterImage(type: FilterType, content: ContentState): Painter { |
||||
return when (type) { |
||||
FilterType.GrayScale -> if (content.isFilterEnabled(type)) icFilterGrayscaleOn() else icFilterGrayscaleOff() |
||||
FilterType.Pixel -> if (content.isFilterEnabled(type)) icFilterPixelOn() else icFilterPixelOff() |
||||
FilterType.Blur -> if (content.isFilterEnabled(type)) icFilterBlurOn() else icFilterBlurOff() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Image(content: ContentState) { |
||||
val drag = remember { DragHandler() } |
||||
val scale = remember { ScaleHandler() } |
||||
|
||||
Surface( |
||||
color = DarkGray, |
||||
modifier = Modifier.fillMaxSize() |
||||
) { |
||||
Draggable(dragHandler = drag, modifier = Modifier.fillMaxSize()) { |
||||
Scalable(onScale = scale, modifier = Modifier.fillMaxSize()) { |
||||
val bitmap = imageByGesture(content, scale, drag) |
||||
Image( |
||||
bitmap = bitmap.asImageBitmap(), |
||||
contentDescription = null, |
||||
contentScale = adjustImageScale(bitmap) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun imageByGesture( |
||||
content: ContentState, |
||||
scale: ScaleHandler, |
||||
drag: DragHandler |
||||
): Bitmap { |
||||
val bitmap = cropBitmapByScale(content.getSelectedImage(), scale.factor.value, drag) |
||||
|
||||
if (scale.factor.value > 1f) |
||||
return bitmap |
||||
|
||||
if (abs(drag.getDistance().x) > displayWidth() / 10) { |
||||
if (drag.getDistance().x < 0) { |
||||
content.swipeNext() |
||||
} else { |
||||
content.swipePrevious() |
||||
} |
||||
drag.cancel() |
||||
} |
||||
|
||||
return bitmap |
||||
} |
@ -0,0 +1,218 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import android.content.res.Configuration |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.foundation.Image |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.offset |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.Card |
||||
import androidx.compose.material.CircularProgressIndicator |
||||
import androidx.compose.material.Divider |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TopAppBar |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.RectangleShape |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
import androidx.compose.ui.graphics.painter.BitmapPainter |
||||
import androidx.compose.ui.layout.ContentScale |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.common.R |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.model.Picture |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.style.DarkGray |
||||
import example.imageviewer.style.DarkGreen |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.LightGray |
||||
import example.imageviewer.style.MiniatureColor |
||||
import example.imageviewer.style.Transparent |
||||
import example.imageviewer.style.icDots |
||||
import example.imageviewer.style.icEmpty |
||||
import example.imageviewer.style.icRefresh |
||||
|
||||
@Composable |
||||
fun MainScreen(content: ContentState) { |
||||
Column { |
||||
TopContent(content) |
||||
ScrollableArea(content) |
||||
} |
||||
if (!content.isContentReady()) { |
||||
LoadingScreen(content.getString(R.string.loading)) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun TopContent(content: ContentState) { |
||||
TitleBar(text = content.getString(R.string.app_name), content = content) |
||||
if (content.getOrientation() == Configuration.ORIENTATION_PORTRAIT) { |
||||
PreviewImage(content) |
||||
Spacer(modifier = Modifier.height(10.dp)) |
||||
Divider() |
||||
} |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
} |
||||
|
||||
@Composable |
||||
fun TitleBar(text: String, content: ContentState) { |
||||
TopAppBar( |
||||
backgroundColor = DarkGreen, |
||||
title = { |
||||
Row(Modifier.height(50.dp)) { |
||||
Text( |
||||
text, |
||||
color = Foreground, |
||||
modifier = Modifier.weight(1f).align(Alignment.CenterVertically) |
||||
) |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
Clickable( |
||||
onClick = { |
||||
if (content.isContentReady()) { |
||||
content.refresh() |
||||
} |
||||
} |
||||
) { |
||||
Image( |
||||
icRefresh(), |
||||
contentDescription = null, |
||||
modifier = Modifier.size(35.dp) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
|
||||
@Composable |
||||
fun PreviewImage(content: ContentState) { |
||||
Clickable(onClick = { |
||||
AppState.screenState(ScreenType.FullscreenImage) |
||||
}) { |
||||
Card( |
||||
backgroundColor = DarkGray, |
||||
modifier = Modifier.height(250.dp), |
||||
shape = RectangleShape, |
||||
elevation = 1.dp |
||||
) { |
||||
Image( |
||||
if (content.isMainImageEmpty()) { |
||||
icEmpty() |
||||
} else { |
||||
BitmapPainter(content.getSelectedImage().asImageBitmap()) |
||||
}, |
||||
contentDescription = null, |
||||
modifier = Modifier |
||||
.fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp), |
||||
contentScale = ContentScale.Fit |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Miniature( |
||||
picture: Picture, |
||||
content: ContentState |
||||
) { |
||||
Card( |
||||
backgroundColor = MiniatureColor, |
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp) |
||||
.fillMaxWidth() |
||||
.clickable { |
||||
content.setMainImage(picture) |
||||
}, |
||||
shape = RectangleShape, |
||||
elevation = 2.dp |
||||
) { |
||||
Row(modifier = Modifier.padding(end = 30.dp)) { |
||||
Clickable( |
||||
onClick = { |
||||
content.fullscreen(picture) |
||||
} |
||||
) { |
||||
Image( |
||||
picture.image.asImageBitmap(), |
||||
contentDescription = null, |
||||
modifier = Modifier.height(70.dp) |
||||
.width(90.dp) |
||||
.padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp), |
||||
contentScale = ContentScale.Crop |
||||
) |
||||
} |
||||
Text( |
||||
text = picture.name, |
||||
color = Foreground, |
||||
modifier = Modifier.weight(1f).align(Alignment.CenterVertically).padding(start = 16.dp), |
||||
style = MaterialTheme.typography.body1 |
||||
) |
||||
|
||||
Clickable( |
||||
modifier = Modifier.height(70.dp) |
||||
.width(30.dp), |
||||
onClick = { |
||||
showPopUpMessage( |
||||
"${content.getString(R.string.picture)} " + |
||||
"${picture.name} \n" + |
||||
"${content.getString(R.string.size)} " + |
||||
"${picture.width}x${picture.height} " + |
||||
"${content.getString(R.string.pixels)}", |
||||
content.getContext() |
||||
) |
||||
} |
||||
) { |
||||
Image( |
||||
icDots(), |
||||
contentDescription = null, |
||||
modifier = Modifier.height(70.dp) |
||||
.width(30.dp) |
||||
.padding(start = 1.dp, top = 25.dp, end = 1.dp, bottom = 25.dp), |
||||
contentScale = ContentScale.FillHeight |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun ScrollableArea(content: ContentState) { |
||||
var index = 1 |
||||
val scrollState = rememberScrollState() |
||||
Column(Modifier.verticalScroll(scrollState)) { |
||||
for (picture in content.getMiniatures()) { |
||||
Miniature( |
||||
picture = picture, |
||||
content = content |
||||
) |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
index++ |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Divider() { |
||||
Divider( |
||||
color = LightGray, |
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp) |
||||
) |
||||
} |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 8.2 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<background android:drawable="@mipmap/ic_imageviewer_background"/> |
||||
<foreground android:drawable="@mipmap/ic_imageviewer_foreground"/> |
||||
</adaptive-icon> |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<background android:drawable="@mipmap/ic_imageviewer_background"/> |
||||
<foreground android:drawable="@mipmap/ic_imageviewer_foreground"/> |
||||
</adaptive-icon> |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 8.7 KiB |
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 107 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 44 KiB |
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources> |
||||
<string name="app_name">ImageViewer</string> |
||||
<string name="loading">Загружаем изображения...</string> |
||||
<string name="repo_empty">Репозиторий пуст.</string> |
||||
<string name="no_internet">Нет доступа в интернет.</string> |
||||
<string name="repo_invalid">Список изображений в репозитории пуст или имеет неверный формат.</string> |
||||
<string name="refresh_unavailable">Невозможно обновить изображения.</string> |
||||
<string name="load_image_unavailable">Невозможно загузить полное изображение.</string> |
||||
<string name="last_image">Это последнее изображение.</string> |
||||
<string name="first_image">Это первое изображение.</string> |
||||
<string name="picture">Изображение:</string> |
||||
<string name="size">Размеры:</string> |
||||
<string name="pixels">пикселей.</string> |
||||
</resources> |
@ -0,0 +1,14 @@
|
||||
<resources> |
||||
<string name="app_name">ImageViewer</string> |
||||
<string name="loading">Loading images...</string> |
||||
<string name="repo_empty">Repository is empty.</string> |
||||
<string name="no_internet">No internet access.</string> |
||||
<string name="repo_invalid">List of images in current repository is invalid or empty.</string> |
||||
<string name="refresh_unavailable">Cannot refresh images.</string> |
||||
<string name="load_image_unavailable">Cannot load full size image.</string> |
||||
<string name="last_image">This is last image.</string> |
||||
<string name="first_image">This is first image.</string> |
||||
<string name="picture">Picture:</string> |
||||
<string name="size">Size:</string> |
||||
<string name="pixels">pixels.</string> |
||||
</resources> |
@ -0,0 +1,18 @@
|
||||
package example.imageviewer.core |
||||
|
||||
class EventLocker { |
||||
|
||||
private var value: Boolean = false |
||||
|
||||
fun lock() { |
||||
value = false |
||||
} |
||||
|
||||
fun unlock() { |
||||
value = true |
||||
} |
||||
|
||||
fun isLocked(): Boolean { |
||||
return value |
||||
} |
||||
} |
@ -0,0 +1,5 @@
|
||||
package example.imageviewer.core |
||||
|
||||
enum class FilterType { |
||||
GrayScale, Pixel, Blur |
||||
} |
@ -0,0 +1,5 @@
|
||||
package example.imageviewer.core |
||||
|
||||
interface Repository<T> { |
||||
fun get() : T |
||||
} |
@ -0,0 +1,33 @@
|
||||
// READ ME FIRST! |
||||
// |
||||
// Code in this file is shared between the Android and Desktop JVM targets. |
||||
// Kotlin's hierarchical multiplatform projects currently |
||||
// don't support sharing code depending on JVM declarations. |
||||
// |
||||
// You can follow the progress for HMPP JVM & Android intermediate source sets here: |
||||
// https://youtrack.jetbrains.com/issue/KT-42466 |
||||
// |
||||
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not |
||||
// resolve symbols in this file properly. |
||||
// |
||||
// Resolution errors in your IDE do not indicate a problem with your setup. |
||||
|
||||
|
||||
package example.imageviewer.model |
||||
|
||||
import example.imageviewer.core.Repository |
||||
import example.imageviewer.utils.ktorHttpClient |
||||
import example.imageviewer.utils.runBlocking |
||||
import io.ktor.client.request.* |
||||
|
||||
class ImageRepository( |
||||
private val httpsURL: String |
||||
) : Repository<MutableList<String>> { |
||||
|
||||
override fun get(): MutableList<String> { |
||||
return runBlocking { |
||||
val content = ktorHttpClient.get<String>(httpsURL) |
||||
content.lines().toMutableList() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,41 @@
|
||||
// READ ME FIRST! |
||||
// |
||||
// Code in this file is shared between the Android and Desktop JVM targets. |
||||
// Kotlin's hierarchical multiplatform projects currently |
||||
// don't support sharing code depending on JVM declarations. |
||||
// |
||||
// You can follow the progress for HMPP JVM & Android intermediate source sets here: |
||||
// https://youtrack.jetbrains.com/issue/KT-42466 |
||||
// |
||||
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not |
||||
// resolve symbols in this file properly. |
||||
// |
||||
// Resolution errors in your IDE do not indicate a problem with your setup. |
||||
|
||||
package example.imageviewer.model |
||||
|
||||
expect class Picture |
||||
|
||||
class Miniatures( |
||||
private var list: List<Picture> = emptyList() |
||||
) { |
||||
fun get(index: Int): Picture { |
||||
return list[index] |
||||
} |
||||
|
||||
fun getMiniatures(): List<Picture> { |
||||
return list.toList() |
||||
} |
||||
|
||||
fun setMiniatures(list: List<Picture>) { |
||||
this.list = list.toList() |
||||
} |
||||
|
||||
fun size(): Int { |
||||
return list.size |
||||
} |
||||
|
||||
fun clear() { |
||||
list = emptyList() |
||||
} |
||||
} |
@ -0,0 +1,23 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
|
||||
enum class ScreenType { |
||||
MainScreen, FullscreenImage |
||||
} |
||||
|
||||
object AppState { |
||||
private var screen: MutableState<ScreenType> |
||||
init { |
||||
screen = mutableStateOf(ScreenType.MainScreen) |
||||
} |
||||
|
||||
fun screenState() : ScreenType { |
||||
return screen.value |
||||
} |
||||
|
||||
fun screenState(state: ScreenType) { |
||||
screen.value = state |
||||
} |
||||
} |
@ -0,0 +1,16 @@
|
||||
package example.imageviewer.style |
||||
|
||||
import androidx.compose.ui.graphics.Color |
||||
|
||||
val DarkGreen = Color(16, 139, 102) |
||||
val Gray = Color.DarkGray |
||||
val LightGray = Color(100, 100, 100) |
||||
val DarkGray = Color(32, 32, 32) |
||||
val PreviewImageAreaHoverColor = Color(45, 45, 45) |
||||
val ToastBackground = Color(23, 23, 23) |
||||
val MiniatureColor = Color(50, 50, 50) |
||||
val MiniatureHoverColor = Color(55, 55, 55) |
||||
val Foreground = Color(210, 210, 210) |
||||
val TranslucentBlack = Color(0, 0, 0, 60) |
||||
val TranslucentWhite = Color(255, 255, 255, 20) |
||||
val Transparent = Color.Transparent |
@ -0,0 +1,7 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlin.coroutines.CoroutineContext |
||||
import kotlin.coroutines.EmptyCoroutineContext |
||||
|
||||
expect fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T |
@ -0,0 +1,37 @@
|
||||
// READ ME FIRST! |
||||
// |
||||
// Code in this file is shared between the Android and Desktop JVM targets. |
||||
// Kotlin's hierarchical multiplatform projects currently |
||||
// don't support sharing code depending on JVM declarations. |
||||
// |
||||
// You can follow the progress for HMPP JVM & Android intermediate source sets here: |
||||
// https://youtrack.jetbrains.com/issue/KT-42466 |
||||
// |
||||
// The workaround used here to access JVM libraries causes IntelliJ IDEA to not |
||||
// resolve symbols in this file properly. |
||||
// |
||||
// Resolution errors in your IDE do not indicate a problem with your setup. |
||||
|
||||
package example.imageviewer.utils |
||||
|
||||
import io.ktor.client.* |
||||
import io.ktor.client.request.* |
||||
import kotlinx.coroutines.Deferred |
||||
import kotlinx.coroutines.GlobalScope |
||||
import kotlinx.coroutines.async |
||||
|
||||
//import java.net.InetAddress |
||||
|
||||
fun isInternetAvailable(): Boolean { |
||||
return runBlocking { |
||||
try { |
||||
ktorHttpClient.head<String>("http://google.com") |
||||
true |
||||
} catch (e: Exception) { |
||||
println(e.message) |
||||
false |
||||
} |
||||
} |
||||
} |
||||
|
||||
val ktorHttpClient = HttpClient {} |
@ -0,0 +1,21 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.ui.Modifier |
||||
|
||||
@Composable |
||||
fun Clickable( |
||||
modifier: Modifier = Modifier, |
||||
onClick: (() -> Unit)? = null, |
||||
children: @Composable () -> Unit = { } |
||||
) { |
||||
Box( |
||||
modifier = modifier.clickable { |
||||
onClick?.invoke() |
||||
} |
||||
) { |
||||
children() |
||||
} |
||||
} |
@ -0,0 +1,88 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.ui.input.pointer.pointerInput |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.geometry.Offset |
||||
import example.imageviewer.core.EventLocker |
||||
import example.imageviewer.style.Transparent |
||||
|
||||
@Composable |
||||
fun Draggable( |
||||
dragHandler: DragHandler, |
||||
modifier: Modifier = Modifier, |
||||
onUpdate: (() -> Unit)? = null, |
||||
children: @Composable() () -> Unit |
||||
) { |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = modifier.pointerInput(Unit) { |
||||
detectDragGestures( |
||||
onDragStart = { dragHandler.reset() }, |
||||
onDragEnd = { dragHandler.reset() }, |
||||
onDragCancel = { dragHandler.cancel() }, |
||||
) { change, dragAmount -> |
||||
dragHandler.drag(dragAmount) |
||||
onUpdate?.invoke() |
||||
change.consume() |
||||
} |
||||
} |
||||
) { |
||||
children() |
||||
} |
||||
} |
||||
|
||||
class DragHandler { |
||||
|
||||
private val amount = mutableStateOf(Point(0f, 0f)) |
||||
private val distance = mutableStateOf(Point(0f, 0f)) |
||||
private val locker: EventLocker = EventLocker() |
||||
|
||||
fun getAmount(): Point { |
||||
return amount.value |
||||
} |
||||
|
||||
fun getDistance(): Point { |
||||
return distance.value |
||||
} |
||||
|
||||
fun reset() { |
||||
distance.value = Point(Offset.Zero) |
||||
locker.unlock() |
||||
} |
||||
|
||||
fun cancel() { |
||||
distance.value = Point(Offset.Zero) |
||||
locker.lock() |
||||
} |
||||
|
||||
fun drag(dragDistance: Offset) { |
||||
if (locker.isLocked()) { |
||||
val dx = dragDistance.x |
||||
val dy = dragDistance.y |
||||
|
||||
distance.value = Point(distance.value.x + dx, distance.value.y + dy) |
||||
amount.value = Point(amount.value.x + dx, amount.value.y + dy) |
||||
} |
||||
} |
||||
} |
||||
|
||||
class Point { |
||||
var x: Float = 0f |
||||
var y: Float = 0f |
||||
constructor(x: Float, y: Float) { |
||||
this.x = x |
||||
this.y = y |
||||
} |
||||
constructor(point: Offset) { |
||||
this.x = point.x |
||||
this.y = point.y |
||||
} |
||||
fun setAttr(x: Float, y: Float) { |
||||
this.x = x |
||||
this.y = y |
||||
} |
||||
} |
@ -0,0 +1,43 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.offset |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.CircularProgressIndicator |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.style.DarkGray |
||||
import example.imageviewer.style.DarkGreen |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.TranslucentBlack |
||||
|
||||
@Composable |
||||
fun LoadingScreen(text: String = "") { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize().background(color = TranslucentBlack) |
||||
) { |
||||
Box(modifier = Modifier.align(Alignment.Center)) { |
||||
Surface(color = DarkGray, elevation = 4.dp, shape = CircleShape) { |
||||
CircularProgressIndicator( |
||||
modifier = Modifier.size(50.dp).padding(3.dp, 3.dp, 4.dp, 4.dp), |
||||
color = DarkGreen |
||||
) |
||||
} |
||||
} |
||||
Text( |
||||
text = text, |
||||
modifier = Modifier.align(Alignment.Center).offset(0.dp, 70.dp), |
||||
style = MaterialTheme.typography.body1, |
||||
color = Foreground |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,47 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures |
||||
import androidx.compose.foundation.gestures.detectTransformGestures |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.input.pointer.pointerInput |
||||
import example.imageviewer.style.Transparent |
||||
|
||||
@Composable |
||||
fun Scalable( |
||||
onScale: ScaleHandler, |
||||
modifier: Modifier = Modifier, |
||||
children: @Composable() () -> Unit |
||||
) { |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = modifier.pointerInput(Unit) { |
||||
detectTapGestures(onDoubleTap = { onScale.reset() }) |
||||
detectTransformGestures { _, _, zoom, _ -> |
||||
onScale.onScale(zoom) |
||||
} |
||||
}, |
||||
) { |
||||
children() |
||||
} |
||||
} |
||||
|
||||
class ScaleHandler(private val maxFactor: Float = 5f, private val minFactor: Float = 1f) { |
||||
val factor = mutableStateOf(1f) |
||||
|
||||
fun reset() { |
||||
if (factor.value > minFactor) |
||||
factor.value = minFactor |
||||
} |
||||
|
||||
fun onScale(scaleFactor: Float): Float { |
||||
factor.value += scaleFactor - 1f |
||||
|
||||
if (maxFactor < factor.value) factor.value = maxFactor |
||||
if (minFactor > factor.value) factor.value = minFactor |
||||
|
||||
return scaleFactor |
||||
} |
||||
} |
@ -0,0 +1,27 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.unit.sp |
||||
import example.imageviewer.style.DarkGray |
||||
|
||||
@Composable |
||||
fun SplashUI() { |
||||
Box(Modifier.fillMaxSize().background(DarkGray)) { |
||||
Text( |
||||
// TODO implement common resources |
||||
"Image Viewer", |
||||
Modifier.align(Alignment.Center), |
||||
color = Color.White, |
||||
fontWeight = FontWeight.Bold, |
||||
fontSize = 100.sp |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,53 @@
|
||||
package example.imageviewer |
||||
|
||||
object ResString { |
||||
|
||||
val appName: String |
||||
val loading: String |
||||
val repoEmpty: String |
||||
val noInternet: String |
||||
val repoInvalid: String |
||||
val refreshUnavailable: String |
||||
val loadImageUnavailable: String |
||||
val lastImage: String |
||||
val firstImage: String |
||||
val picture: String |
||||
val size: String |
||||
val pixels: String |
||||
val back: String |
||||
val refresh: String |
||||
|
||||
init { |
||||
if (System.getProperty("user.language").equals("ru")) { |
||||
appName = "ImageViewer" |
||||
loading = "Загружаем изображения..." |
||||
repoEmpty = "Репозиторий пуст." |
||||
noInternet = "Нет доступа в интернет." |
||||
repoInvalid = "Список изображений в репозитории пуст или имеет неверный формат." |
||||
refreshUnavailable = "Невозможно обновить изображения." |
||||
loadImageUnavailable = "Невозможно загузить полное изображение." |
||||
lastImage = "Это последнее изображение." |
||||
firstImage = "Это первое изображение." |
||||
picture = "Изображение:" |
||||
size = "Размеры:" |
||||
pixels = "пикселей." |
||||
back = "Назад" |
||||
refresh = "Обновить" |
||||
} else { |
||||
appName = "ImageViewer" |
||||
loading = "Loading images..." |
||||
repoEmpty = "Repository is empty." |
||||
noInternet = "No internet access." |
||||
repoInvalid = "List of images in current repository is invalid or empty." |
||||
refreshUnavailable = "Cannot refresh images." |
||||
loadImageUnavailable = "Cannot load full size image." |
||||
lastImage = "This is last image." |
||||
firstImage = "This is first image." |
||||
picture = "Picture:" |
||||
size = "Size:" |
||||
pixels = "pixels." |
||||
back = "Back" |
||||
refresh = "Refresh" |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,7 @@
|
||||
package example.imageviewer.core |
||||
|
||||
import java.awt.image.BufferedImage |
||||
|
||||
interface BitmapFilter { |
||||
fun apply(bitmap: BufferedImage) : BufferedImage |
||||
} |
@ -0,0 +1,362 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.ui.window.WindowState |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.toComposeImageBitmap |
||||
import example.imageviewer.ResString |
||||
import example.imageviewer.core.FilterType |
||||
import example.imageviewer.model.filtration.FiltersManager |
||||
import example.imageviewer.utils.cacheImagePath |
||||
import example.imageviewer.utils.clearCache |
||||
import example.imageviewer.utils.isInternetAvailable |
||||
import example.imageviewer.view.showPopUpMessage |
||||
import example.imageviewer.view.DragHandler |
||||
import example.imageviewer.view.ScaleHandler |
||||
import example.imageviewer.utils.cropBitmapByScale |
||||
import example.imageviewer.utils.toByteArray |
||||
import java.awt.image.BufferedImage |
||||
import java.io.File |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.launch |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import org.jetbrains.skia.Image |
||||
|
||||
object ContentState { |
||||
val drag = DragHandler() |
||||
val scale = ScaleHandler() |
||||
lateinit var windowState: WindowState |
||||
private lateinit var repository: ImageRepository |
||||
private lateinit var uriRepository: String |
||||
val scope = CoroutineScope(Dispatchers.IO) |
||||
|
||||
fun applyContent(state: WindowState, uriRepository: String): ContentState { |
||||
windowState = state |
||||
if (this::uriRepository.isInitialized && this.uriRepository == uriRepository) { |
||||
return this |
||||
} |
||||
this.uriRepository = uriRepository |
||||
repository = ImageRepository(uriRepository) |
||||
isContentReady.value = false |
||||
|
||||
initData() |
||||
|
||||
return this |
||||
} |
||||
|
||||
private val isAppReady = mutableStateOf(false) |
||||
fun isAppReady(): Boolean { |
||||
return isAppReady.value |
||||
} |
||||
|
||||
private val isContentReady = mutableStateOf(false) |
||||
fun isContentReady(): Boolean { |
||||
return isContentReady.value |
||||
} |
||||
|
||||
// drawable content |
||||
private val mainImage = mutableStateOf(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) |
||||
private val currentImageIndex = mutableStateOf(0) |
||||
private val miniatures = Miniatures() |
||||
|
||||
fun getMiniatures(): List<Picture> { |
||||
return miniatures.getMiniatures() |
||||
} |
||||
|
||||
fun getSelectedImage(): ImageBitmap { |
||||
return MainImageWrapper.mainImageAsImageBitmap.value |
||||
} |
||||
|
||||
fun getSelectedImageName(): String { |
||||
return MainImageWrapper.getName() |
||||
} |
||||
|
||||
// filters managing |
||||
private val appliedFilters = FiltersManager() |
||||
private val filterUIState: MutableMap<FilterType, MutableState<Boolean>> = LinkedHashMap() |
||||
|
||||
private fun toggleFilterState(filter: FilterType) { |
||||
if (!filterUIState.containsKey(filter)) { |
||||
filterUIState[filter] = mutableStateOf(true) |
||||
} else { |
||||
val value = filterUIState[filter]!!.value |
||||
filterUIState[filter]!!.value = !value |
||||
} |
||||
} |
||||
|
||||
fun toggleFilter(filter: FilterType) { |
||||
if (containsFilter(filter)) { |
||||
removeFilter(filter) |
||||
} else { |
||||
addFilter(filter) |
||||
} |
||||
|
||||
toggleFilterState(filter) |
||||
|
||||
var bitmap = MainImageWrapper.origin |
||||
|
||||
if (bitmap != null) { |
||||
bitmap = appliedFilters.applyFilters(bitmap) |
||||
MainImageWrapper.setImage(bitmap) |
||||
mainImage.value = bitmap |
||||
updateMainImage() |
||||
} |
||||
} |
||||
|
||||
private fun addFilter(filter: FilterType) { |
||||
appliedFilters.add(filter) |
||||
MainImageWrapper.addFilter(filter) |
||||
} |
||||
|
||||
private fun removeFilter(filter: FilterType) { |
||||
appliedFilters.remove(filter) |
||||
MainImageWrapper.removeFilter(filter) |
||||
} |
||||
|
||||
private fun containsFilter(type: FilterType): Boolean { |
||||
return appliedFilters.contains(type) |
||||
} |
||||
|
||||
fun isFilterEnabled(type: FilterType): Boolean { |
||||
if (!filterUIState.containsKey(type)) { |
||||
filterUIState[type] = mutableStateOf(false) |
||||
} |
||||
return filterUIState[type]!!.value |
||||
} |
||||
|
||||
private fun restoreFilters(): BufferedImage { |
||||
filterUIState.clear() |
||||
appliedFilters.clear() |
||||
return MainImageWrapper.restore() |
||||
} |
||||
|
||||
fun restoreMainImage() { |
||||
mainImage.value = restoreFilters() |
||||
} |
||||
|
||||
// application content initialization |
||||
private fun initData() { |
||||
if (isContentReady.value) |
||||
return |
||||
|
||||
val directory = File(cacheImagePath) |
||||
if (!directory.exists()) { |
||||
directory.mkdir() |
||||
} |
||||
|
||||
scope.launch(Dispatchers.IO) { |
||||
try { |
||||
if (isInternetAvailable()) { |
||||
val imageList = repository.get() |
||||
|
||||
if (imageList.isEmpty()) { |
||||
showPopUpMessage( |
||||
ResString.repoInvalid |
||||
) |
||||
onContentReady() |
||||
} else { |
||||
val pictureList = loadImages(cacheImagePath, imageList) |
||||
|
||||
if (pictureList.isEmpty()) { |
||||
showPopUpMessage( |
||||
ResString.repoEmpty |
||||
) |
||||
onContentReady() |
||||
} else { |
||||
val picture = loadFullImage(imageList[0]) |
||||
miniatures.setMiniatures(pictureList) |
||||
if (isMainImageEmpty()) { |
||||
wrapPictureIntoMainImage(picture) |
||||
} else { |
||||
appliedFilters.add(MainImageWrapper.getFilters()) |
||||
currentImageIndex.value = MainImageWrapper.getId() |
||||
} |
||||
onContentReady() |
||||
} |
||||
} |
||||
} else { |
||||
showPopUpMessage( |
||||
ResString.noInternet |
||||
) |
||||
onContentReady() |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
} |
||||
|
||||
// preview/fullscreen image managing |
||||
fun isMainImageEmpty(): Boolean { |
||||
return MainImageWrapper.isEmpty() |
||||
} |
||||
|
||||
fun fullscreen(picture: Picture) { |
||||
isContentReady.value = false |
||||
AppState.screenState(ScreenType.FullscreenImage) |
||||
setMainImage(picture) |
||||
} |
||||
|
||||
fun setMainImage(picture: Picture) { |
||||
if (MainImageWrapper.getId() == picture.id) { |
||||
if (!isContentReady()) { |
||||
onContentReady() |
||||
} |
||||
return |
||||
} |
||||
isContentReady.value = false |
||||
|
||||
scope.launch(Dispatchers.IO) { |
||||
scale.reset() |
||||
if (isInternetAvailable()) { |
||||
val fullSizePicture = loadFullImage(picture.source) |
||||
fullSizePicture.id = picture.id |
||||
wrapPictureIntoMainImage(fullSizePicture) |
||||
} else { |
||||
showPopUpMessage( |
||||
"${ResString.noInternet}\n${ResString.loadImageUnavailable}" |
||||
) |
||||
wrapPictureIntoMainImage(picture) |
||||
} |
||||
onContentReady() |
||||
} |
||||
} |
||||
|
||||
private fun onContentReady() { |
||||
isContentReady.value = true |
||||
isAppReady.value = true |
||||
} |
||||
|
||||
private fun wrapPictureIntoMainImage(picture: Picture) { |
||||
MainImageWrapper.wrapPicture(picture) |
||||
MainImageWrapper.saveOrigin() |
||||
mainImage.value = picture.image |
||||
currentImageIndex.value = picture.id |
||||
updateMainImage() |
||||
} |
||||
|
||||
fun updateMainImage() { |
||||
MainImageWrapper.mainImageAsImageBitmap.value = Image.makeFromEncoded( |
||||
toByteArray( |
||||
cropBitmapByScale( |
||||
mainImage.value, |
||||
windowState.size, |
||||
scale.factor.value, |
||||
drag |
||||
) |
||||
) |
||||
).toComposeImageBitmap() |
||||
} |
||||
|
||||
fun swipeNext() { |
||||
if (currentImageIndex.value == miniatures.size() - 1) { |
||||
showPopUpMessage(ResString.lastImage) |
||||
return |
||||
} |
||||
|
||||
restoreFilters() |
||||
setMainImage(miniatures.get(++currentImageIndex.value)) |
||||
} |
||||
|
||||
fun swipePrevious() { |
||||
if (currentImageIndex.value == 0) { |
||||
showPopUpMessage(ResString.firstImage) |
||||
return |
||||
} |
||||
|
||||
restoreFilters() |
||||
setMainImage(miniatures.get(--currentImageIndex.value)) |
||||
} |
||||
|
||||
fun refresh() { |
||||
scope.launch(Dispatchers.IO) { |
||||
if (isInternetAvailable()) { |
||||
clearCache() |
||||
MainImageWrapper.clear() |
||||
miniatures.clear() |
||||
isContentReady.value = false |
||||
initData() |
||||
} else { |
||||
showPopUpMessage( |
||||
"${ResString.noInternet}\n${ResString.refreshUnavailable}" |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private object MainImageWrapper { |
||||
// origin image |
||||
var origin: BufferedImage? = null |
||||
private set |
||||
|
||||
fun saveOrigin() { |
||||
origin = copy(picture.value.image) |
||||
} |
||||
|
||||
fun restore(): BufferedImage { |
||||
if (origin != null) { |
||||
picture.value.image = copy(origin!!) |
||||
filtersSet.clear() |
||||
} |
||||
return copy(picture.value.image) |
||||
} |
||||
|
||||
var mainImageAsImageBitmap = mutableStateOf(ImageBitmap(1, 1)) |
||||
|
||||
// picture adapter |
||||
private var picture = mutableStateOf( |
||||
Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) |
||||
) |
||||
|
||||
fun wrapPicture(picture: Picture) { |
||||
this.picture.value = picture |
||||
} |
||||
|
||||
fun setImage(bitmap: BufferedImage) { |
||||
picture.value.image = bitmap |
||||
} |
||||
|
||||
fun isEmpty(): Boolean { |
||||
return (picture.value.name == "") |
||||
} |
||||
|
||||
fun clear() { |
||||
picture.value = Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) |
||||
} |
||||
|
||||
fun getName(): String { |
||||
return picture.value.name |
||||
} |
||||
|
||||
fun getImage(): BufferedImage { |
||||
return picture.value.image |
||||
} |
||||
|
||||
fun getId(): Int { |
||||
return picture.value.id |
||||
} |
||||
|
||||
// applied filters |
||||
private var filtersSet: MutableSet<FilterType> = LinkedHashSet() |
||||
|
||||
fun addFilter(filter: FilterType) { |
||||
filtersSet.add(filter) |
||||
} |
||||
|
||||
fun removeFilter(filter: FilterType) { |
||||
filtersSet.remove(filter) |
||||
} |
||||
|
||||
fun getFilters(): Set<FilterType> { |
||||
return filtersSet |
||||
} |
||||
|
||||
private fun copy(bitmap: BufferedImage) : BufferedImage { |
||||
val result = BufferedImage(bitmap.width, bitmap.height, bitmap.type) |
||||
val graphics = result.createGraphics() |
||||
graphics.drawImage(bitmap, 0, 0, result.width, result.height, null) |
||||
return result |
||||
} |
||||
} |
@ -0,0 +1,130 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.utils.cacheImage |
||||
import example.imageviewer.utils.cacheImagePostfix |
||||
import example.imageviewer.utils.scaleBitmapAspectRatio |
||||
import java.io.File |
||||
import java.io.FileInputStream |
||||
import java.io.InputStream |
||||
import java.io.InputStreamReader |
||||
import java.io.BufferedReader |
||||
import javax.imageio.ImageIO |
||||
import java.lang.Exception |
||||
import java.net.HttpURLConnection |
||||
import java.net.URL |
||||
import java.nio.charset.StandardCharsets |
||||
|
||||
fun loadFullImage(source: String): Picture { |
||||
try { |
||||
val url = URL(source) |
||||
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection |
||||
connection.connectTimeout = 5000 |
||||
connection.connect() |
||||
|
||||
val input: InputStream = connection.inputStream |
||||
val bitmap: BufferedImage? = ImageIO.read(input) |
||||
if (bitmap != null) { |
||||
return Picture( |
||||
source = source, |
||||
image = bitmap, |
||||
name = getNameURL(source), |
||||
width = bitmap.width, |
||||
height = bitmap.height |
||||
) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
|
||||
return Picture(image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) |
||||
} |
||||
|
||||
fun loadImages(cachePath: String, list: List<String>): MutableList<Picture> { |
||||
val result: MutableList<Picture> = ArrayList() |
||||
|
||||
for (source in list) { |
||||
val name = getNameURL(source) |
||||
val path = cachePath + File.separator + name |
||||
|
||||
if (File(path + "info").exists()) { |
||||
addCachedMiniature(filePath = path, outList = result) |
||||
} else { |
||||
addFreshMiniature(source = source, outList = result, path = cachePath) |
||||
} |
||||
|
||||
result.last().id = result.size - 1 |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
private fun addFreshMiniature( |
||||
source: String, |
||||
outList: MutableList<Picture>, |
||||
path: String |
||||
) { |
||||
try { |
||||
val url = URL(source) |
||||
val connection: HttpURLConnection = url.openConnection() as HttpURLConnection |
||||
connection.connectTimeout = 5000 |
||||
connection.connect() |
||||
|
||||
val input: InputStream = connection.inputStream |
||||
val result: BufferedImage? = ImageIO.read(input) |
||||
|
||||
if (result != null) { |
||||
val picture = Picture( |
||||
source, |
||||
getNameURL(source), |
||||
scaleBitmapAspectRatio(result, 200, 164), |
||||
result.width, |
||||
result.height |
||||
) |
||||
|
||||
outList.add(picture) |
||||
cacheImage(path + getNameURL(source), picture) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
private fun addCachedMiniature( |
||||
filePath: String, |
||||
outList: MutableList<Picture> |
||||
) { |
||||
try { |
||||
val read = BufferedReader( |
||||
InputStreamReader( |
||||
FileInputStream(filePath + cacheImagePostfix), |
||||
StandardCharsets.UTF_8 |
||||
) |
||||
) |
||||
|
||||
val source = read.readLine() |
||||
val width = read.readLine().toInt() |
||||
val height = read.readLine().toInt() |
||||
|
||||
read.close() |
||||
|
||||
val result: BufferedImage? = ImageIO.read(File(filePath)) |
||||
|
||||
if (result != null) { |
||||
val picture = Picture( |
||||
source, |
||||
getNameURL(source), |
||||
result, |
||||
width, |
||||
height |
||||
) |
||||
outList.add(picture) |
||||
} |
||||
} catch (e: Exception) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
private fun getNameURL(url: String): String { |
||||
return url.substring(url.lastIndexOf('/') + 1, url.length) |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model |
||||
|
||||
import java.awt.image.BufferedImage |
||||
|
||||
actual data class Picture( |
||||
var source: String = "", |
||||
var name: String = "", |
||||
var image: BufferedImage, |
||||
var width: Int = 0, |
||||
var height: Int = 0, |
||||
var id: Int = 0 |
||||
) |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyBlurFilter |
||||
|
||||
class BlurFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: BufferedImage): BufferedImage { |
||||
return applyBlurFilter(bitmap) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.core.BitmapFilter |
||||
|
||||
class EmptyFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: BufferedImage): BufferedImage { |
||||
return bitmap |
||||
} |
||||
} |
@ -0,0 +1,53 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.core.FilterType |
||||
|
||||
class FiltersManager { |
||||
|
||||
private var filtersMap: MutableMap<FilterType, BitmapFilter> = LinkedHashMap() |
||||
|
||||
fun clear() { |
||||
filtersMap = LinkedHashMap() |
||||
} |
||||
|
||||
fun add(filters: Collection<FilterType>) { |
||||
|
||||
for (filter in filters) |
||||
add(filter) |
||||
} |
||||
|
||||
fun add(filter: FilterType) { |
||||
|
||||
if (!filtersMap.containsKey(filter)) |
||||
filtersMap[filter] = getFilter(filter) |
||||
} |
||||
|
||||
fun remove(filter: FilterType) { |
||||
filtersMap.remove(filter) |
||||
} |
||||
|
||||
fun contains(filter: FilterType): Boolean { |
||||
return filtersMap.contains(filter) |
||||
} |
||||
|
||||
fun applyFilters(bitmap: BufferedImage): BufferedImage { |
||||
|
||||
var result: BufferedImage = bitmap |
||||
for (filter in filtersMap) { |
||||
result = filter.value.apply(result) |
||||
} |
||||
|
||||
return result |
||||
} |
||||
} |
||||
|
||||
private fun getFilter(type: FilterType): BitmapFilter { |
||||
|
||||
return when (type) { |
||||
FilterType.GrayScale -> GrayScaleFilter() |
||||
FilterType.Pixel -> PixelFilter() |
||||
FilterType.Blur -> BlurFilter() |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyGrayScaleFilter |
||||
|
||||
class GrayScaleFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: BufferedImage) : BufferedImage { |
||||
return applyGrayScaleFilter(bitmap) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
package example.imageviewer.model.filtration |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.core.BitmapFilter |
||||
import example.imageviewer.utils.applyPixelFilter |
||||
|
||||
class PixelFilter : BitmapFilter { |
||||
|
||||
override fun apply(bitmap: BufferedImage): BufferedImage { |
||||
return applyPixelFilter(bitmap) |
||||
} |
||||
} |
@ -0,0 +1,42 @@
|
||||
package example.imageviewer.style |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.res.painterResource |
||||
import java.awt.image.BufferedImage |
||||
import javax.imageio.ImageIO |
||||
|
||||
@Composable |
||||
fun icEmpty() = painterResource("images/empty.png") |
||||
|
||||
@Composable |
||||
fun icBack() = painterResource("images/back.png") |
||||
|
||||
@Composable |
||||
fun icRefresh() = painterResource("images/refresh.png") |
||||
|
||||
@Composable |
||||
fun icDots() = painterResource("images/dots.png") |
||||
|
||||
@Composable |
||||
fun icFilterGrayscaleOn() = painterResource("images/grayscale_on.png") |
||||
|
||||
@Composable |
||||
fun icFilterGrayscaleOff() = painterResource("images/grayscale_off.png") |
||||
|
||||
@Composable |
||||
fun icFilterPixelOn() = painterResource("images/pixel_on.png") |
||||
|
||||
@Composable |
||||
fun icFilterPixelOff() = painterResource("images/pixel_off.png") |
||||
|
||||
@Composable |
||||
fun icFilterBlurOn() = painterResource("images/blur_on.png") |
||||
|
||||
@Composable |
||||
fun icFilterBlurOff() = painterResource("images/blur_off.png") |
||||
|
||||
@Composable |
||||
fun icFilterUnknown() = painterResource("images/filter_unknown.png") |
||||
|
||||
@Composable |
||||
fun icAppRounded() = painterResource("images/ic_imageviewer_round.png") |
@ -0,0 +1,53 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import java.awt.image.BufferedImage |
||||
import example.imageviewer.model.Picture |
||||
import javax.imageio.ImageIO |
||||
import java.io.File |
||||
import java.io.BufferedWriter |
||||
import java.io.OutputStreamWriter |
||||
import java.io.FileOutputStream |
||||
import java.io.IOException |
||||
import java.nio.charset.StandardCharsets |
||||
|
||||
val cacheImagePostfix = "info" |
||||
val cacheImagePath = System.getProperty("user.home")!! + |
||||
File.separator + "Pictures/imageviewer" + File.separator |
||||
|
||||
fun cacheImage(path: String, picture: Picture) { |
||||
try { |
||||
ImageIO.write(picture.image, "png", File(path)) |
||||
|
||||
val bw = |
||||
BufferedWriter( |
||||
OutputStreamWriter( |
||||
FileOutputStream(path + cacheImagePostfix), |
||||
StandardCharsets.UTF_8 |
||||
) |
||||
) |
||||
|
||||
bw.write(picture.source) |
||||
bw.write("\r\n${picture.width}") |
||||
bw.write("\r\n${picture.height}") |
||||
bw.close() |
||||
|
||||
} catch (e: IOException) { |
||||
e.printStackTrace() |
||||
} |
||||
} |
||||
|
||||
fun clearCache() { |
||||
|
||||
val directory = File(cacheImagePath) |
||||
|
||||
val files: Array<File>? = directory.listFiles() |
||||
|
||||
if (files != null) { |
||||
for (file in files) { |
||||
if (file.isDirectory) |
||||
continue |
||||
|
||||
file.delete() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,9 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlin.coroutines.CoroutineContext |
||||
|
||||
actual fun <T> runBlocking( |
||||
context: CoroutineContext, |
||||
block: suspend CoroutineScope.() -> T |
||||
): T = kotlinx.coroutines.runBlocking(context, block) |
@ -0,0 +1,206 @@
|
||||
package example.imageviewer.utils |
||||
|
||||
import androidx.compose.ui.unit.DpSize |
||||
import androidx.compose.ui.unit.dp |
||||
import java.awt.Dimension |
||||
import java.awt.Graphics2D |
||||
import java.awt.Rectangle |
||||
import java.awt.Toolkit |
||||
import java.awt.image.BufferedImage |
||||
import java.io.ByteArrayOutputStream |
||||
import java.io.File |
||||
import java.io.IOException |
||||
import javax.imageio.ImageIO |
||||
import java.awt.image.BufferedImageOp |
||||
import java.awt.image.ConvolveOp |
||||
import java.awt.image.Kernel |
||||
import kotlin.math.pow |
||||
import kotlin.math.roundToInt |
||||
import example.imageviewer.view.DragHandler |
||||
|
||||
fun scaleBitmapAspectRatio( |
||||
bitmap: BufferedImage, |
||||
width: Int, |
||||
height: Int |
||||
): BufferedImage { |
||||
val boundW: Float = width.toFloat() |
||||
val boundH: Float = height.toFloat() |
||||
|
||||
val ratioX: Float = boundW / bitmap.width |
||||
val ratioY: Float = boundH / bitmap.height |
||||
val ratio: Float = if (ratioX < ratioY) ratioX else ratioY |
||||
|
||||
val resultH = (bitmap.height * ratio).toInt() |
||||
val resultW = (bitmap.width * ratio).toInt() |
||||
|
||||
val result = BufferedImage(resultW, resultH, BufferedImage.TYPE_INT_ARGB) |
||||
val graphics = result.createGraphics() |
||||
graphics.drawImage(bitmap, 0, 0, resultW, resultH, null) |
||||
graphics.dispose() |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun getDisplayBounds(bitmap: BufferedImage, windowSize: DpSize): Rectangle { |
||||
|
||||
val boundW: Float = windowSize.width.value.toFloat() |
||||
val boundH: Float = windowSize.height.value.toFloat() |
||||
|
||||
val ratioX: Float = bitmap.width / boundW |
||||
val ratioY: Float = bitmap.height / boundH |
||||
|
||||
val ratio: Float = if (ratioX > ratioY) ratioX else ratioY |
||||
|
||||
val resultW = (boundW * ratio) |
||||
val resultH = (boundH * ratio) |
||||
|
||||
return Rectangle(0, 0, resultW.toInt(), resultH.toInt()) |
||||
} |
||||
|
||||
fun applyGrayScaleFilter(bitmap: BufferedImage): BufferedImage { |
||||
|
||||
val result = BufferedImage( |
||||
bitmap.getWidth(), |
||||
bitmap.getHeight(), |
||||
BufferedImage.TYPE_BYTE_GRAY) |
||||
|
||||
val graphics = result.getGraphics() |
||||
graphics.drawImage(bitmap, 0, 0, null) |
||||
graphics.dispose() |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun applyPixelFilter(bitmap: BufferedImage): BufferedImage { |
||||
|
||||
val w: Int = bitmap.width |
||||
val h: Int = bitmap.height |
||||
|
||||
var result = scaleBitmapAspectRatio(bitmap, w / 20, h / 20) |
||||
result = scaleBitmapAspectRatio(result, w, h) |
||||
|
||||
return result |
||||
} |
||||
|
||||
fun applyBlurFilter(bitmap: BufferedImage): BufferedImage { |
||||
|
||||
var result = BufferedImage(bitmap.getWidth(), bitmap.getHeight(), bitmap.type) |
||||
|
||||
val graphics = result.getGraphics() |
||||
graphics.drawImage(bitmap, 0, 0, null) |
||||
graphics.dispose() |
||||
|
||||
val radius = 11 |
||||
val size = 11 |
||||
val weight: Float = 1.0f / (size * size) |
||||
val matrix = FloatArray(size * size) |
||||
|
||||
for (i in 0..matrix.size - 1) { |
||||
matrix[i] = weight |
||||
} |
||||
|
||||
val kernel = Kernel(radius, size, matrix) |
||||
val op = ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null) |
||||
result = op.filter(result, null) |
||||
|
||||
return result.getSubimage( |
||||
radius, |
||||
radius, |
||||
result.width - radius * 2, |
||||
result.height - radius * 2 |
||||
) |
||||
} |
||||
|
||||
fun toByteArray(bitmap: BufferedImage) : ByteArray { |
||||
val baos = ByteArrayOutputStream() |
||||
ImageIO.write(bitmap, "png", baos) |
||||
return baos.toByteArray() |
||||
} |
||||
|
||||
fun cropImage(bitmap: BufferedImage, crop: Rectangle) : BufferedImage { |
||||
return bitmap.getSubimage(crop.x, crop.y, crop.width, crop.height) |
||||
} |
||||
|
||||
fun cropBitmapByScale( |
||||
bitmap: BufferedImage, |
||||
size: DpSize, |
||||
scale: Float, |
||||
drag: DragHandler |
||||
): BufferedImage { |
||||
val crop = cropBitmapByBounds( |
||||
bitmap, |
||||
getDisplayBounds(bitmap, size), |
||||
size, |
||||
scale, |
||||
drag |
||||
) |
||||
return cropImage( |
||||
bitmap, |
||||
Rectangle(crop.x, crop.y, crop.width - crop.x, crop.height - crop.y) |
||||
) |
||||
} |
||||
|
||||
fun cropBitmapByBounds( |
||||
bitmap: BufferedImage, |
||||
bounds: Rectangle, |
||||
size: DpSize, |
||||
scaleFactor: Float, |
||||
drag: DragHandler |
||||
): Rectangle { |
||||
|
||||
if (scaleFactor <= 1f) { |
||||
return Rectangle(0, 0, bitmap.width, bitmap.height) |
||||
} |
||||
|
||||
var scale = scaleFactor.toDouble().pow(1.4) |
||||
|
||||
var boundW = (bounds.width / scale).roundToInt() |
||||
var boundH = (bounds.height / scale).roundToInt() |
||||
|
||||
scale *= size.width.value / bounds.width.toDouble() |
||||
|
||||
val offsetX = drag.getAmount().x / scale |
||||
val offsetY = drag.getAmount().y / scale |
||||
|
||||
if (boundW > bitmap.width) { |
||||
boundW = bitmap.width |
||||
} |
||||
if (boundH > bitmap.height) { |
||||
boundH = bitmap.height |
||||
} |
||||
|
||||
val invisibleW = bitmap.width - boundW |
||||
var leftOffset = (invisibleW / 2.0 - offsetX).roundToInt() |
||||
|
||||
if (leftOffset > invisibleW) { |
||||
leftOffset = invisibleW |
||||
drag.getAmount().x = -((invisibleW / 2.0) * scale).roundToInt().toFloat() |
||||
} |
||||
if (leftOffset < 0) { |
||||
drag.getAmount().x = ((invisibleW / 2.0) * scale).roundToInt().toFloat() |
||||
leftOffset = 0 |
||||
} |
||||
|
||||
val invisibleH = bitmap.height - boundH |
||||
var topOffset = (invisibleH / 2 - offsetY).roundToInt() |
||||
|
||||
if (topOffset > invisibleH) { |
||||
topOffset = invisibleH |
||||
drag.getAmount().y = -((invisibleH / 2.0) * scale).roundToInt().toFloat() |
||||
} |
||||
if (topOffset < 0) { |
||||
drag.getAmount().y = ((invisibleH / 2.0) * scale).roundToInt().toFloat() |
||||
topOffset = 0 |
||||
} |
||||
|
||||
return Rectangle(leftOffset, topOffset, leftOffset + boundW, topOffset + boundH) |
||||
} |
||||
|
||||
fun getPreferredWindowSize(desiredWidth: Int, desiredHeight: Int): DpSize { |
||||
val screenSize: Dimension = Toolkit.getDefaultToolkit().screenSize |
||||
val preferredWidth: Int = (screenSize.width * 0.8f).toInt() |
||||
val preferredHeight: Int = (screenSize.height * 0.8f).toInt() |
||||
val width: Int = if (desiredWidth < preferredWidth) desiredWidth else preferredWidth |
||||
val height: Int = if (desiredHeight < preferredHeight) desiredHeight else preferredHeight |
||||
return DpSize(width.dp, height.dp) |
||||
} |
@ -0,0 +1,40 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.style.Gray |
||||
|
||||
private val message: MutableState<String> = mutableStateOf("") |
||||
private val state: MutableState<Boolean> = mutableStateOf(false) |
||||
|
||||
@Composable |
||||
fun AppUI(content: ContentState) { |
||||
|
||||
Surface( |
||||
modifier = Modifier.fillMaxSize(), |
||||
color = Gray |
||||
) { |
||||
when (AppState.screenState()) { |
||||
ScreenType.MainScreen -> { |
||||
MainScreen(content) |
||||
} |
||||
ScreenType.FullscreenImage -> { |
||||
FullscreenImage(content) |
||||
} |
||||
} |
||||
} |
||||
|
||||
Toast(message.value, state) |
||||
} |
||||
|
||||
fun showPopUpMessage(text: String) { |
||||
message.value = text |
||||
state.value = true |
||||
} |
@ -0,0 +1,207 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.* |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.clip |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.graphics.painter.Painter |
||||
import androidx.compose.ui.input.key.Key |
||||
import androidx.compose.ui.input.key.key |
||||
import androidx.compose.ui.input.key.type |
||||
import androidx.compose.ui.input.key.KeyEventType |
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent |
||||
import androidx.compose.ui.layout.ContentScale |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.core.FilterType |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.ResString |
||||
import example.imageviewer.style.DarkGray |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.MiniatureColor |
||||
import example.imageviewer.style.TranslucentBlack |
||||
import example.imageviewer.style.Transparent |
||||
import example.imageviewer.style.icBack |
||||
import example.imageviewer.style.icFilterBlurOff |
||||
import example.imageviewer.style.icFilterBlurOn |
||||
import example.imageviewer.style.icFilterGrayscaleOff |
||||
import example.imageviewer.style.icFilterGrayscaleOn |
||||
import example.imageviewer.style.icFilterPixelOff |
||||
import example.imageviewer.style.icFilterPixelOn |
||||
|
||||
@Composable |
||||
fun FullscreenImage( |
||||
content: ContentState |
||||
) { |
||||
Column { |
||||
ToolBar(content.getSelectedImageName(), content) |
||||
Image(content) |
||||
} |
||||
if (!content.isContentReady()) { |
||||
LoadingScreen() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun ToolBar( |
||||
text: String, |
||||
content: ContentState |
||||
) { |
||||
val backButtonInteractionSource = remember { MutableInteractionSource() } |
||||
val backButtonHover by backButtonInteractionSource.collectIsHoveredAsState() |
||||
Surface( |
||||
color = MiniatureColor, |
||||
modifier = Modifier.height(44.dp) |
||||
) { |
||||
Row(modifier = Modifier.padding(end = 30.dp)) { |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = Modifier.padding(start = 20.dp).align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
Tooltip(ResString.back) { |
||||
Clickable( |
||||
modifier = Modifier |
||||
.hoverable(backButtonInteractionSource) |
||||
.background(color = if (backButtonHover) TranslucentBlack else Transparent), |
||||
onClick = { |
||||
if (content.isContentReady()) { |
||||
content.restoreMainImage() |
||||
AppState.screenState(ScreenType.MainScreen) |
||||
} |
||||
}) { |
||||
Image( |
||||
icBack(), |
||||
contentDescription = null, |
||||
modifier = Modifier.size(38.dp) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
Text( |
||||
text, |
||||
color = Foreground, |
||||
maxLines = 1, |
||||
modifier = Modifier.padding(start = 30.dp).weight(1f) |
||||
.align(Alignment.CenterVertically), |
||||
style = MaterialTheme.typography.body1 |
||||
) |
||||
|
||||
Surface( |
||||
color = Color(255, 255, 255, 40), |
||||
modifier = Modifier.size(154.dp, 38.dp) |
||||
.align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
val state = rememberScrollState(0) |
||||
Row(modifier = Modifier.horizontalScroll(state)) { |
||||
Row { |
||||
for (type in FilterType.values()) { |
||||
FilterButton(content, type) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun FilterButton( |
||||
content: ContentState, |
||||
type: FilterType, |
||||
modifier: Modifier = Modifier.size(38.dp) |
||||
) { |
||||
val interactionSource = remember { MutableInteractionSource() } |
||||
val filterButtonHover by interactionSource.collectIsHoveredAsState() |
||||
Box( |
||||
modifier = Modifier.background(color = Transparent).clip(CircleShape) |
||||
) { |
||||
Tooltip("$type") { |
||||
Clickable( |
||||
modifier = Modifier |
||||
.hoverable(interactionSource) |
||||
.background(color = if (filterButtonHover) TranslucentBlack else Transparent), |
||||
onClick = { content.toggleFilter(type)} |
||||
) { |
||||
Image( |
||||
getFilterImage(type = type, content = content), |
||||
contentDescription = null, |
||||
modifier |
||||
) |
||||
} |
||||
} |
||||
} |
||||
Spacer(Modifier.width(20.dp)) |
||||
} |
||||
|
||||
@Composable |
||||
fun getFilterImage(type: FilterType, content: ContentState): Painter { |
||||
return when (type) { |
||||
FilterType.GrayScale -> if (content.isFilterEnabled(type)) icFilterGrayscaleOn() else icFilterGrayscaleOff() |
||||
FilterType.Pixel -> if (content.isFilterEnabled(type)) icFilterPixelOn() else icFilterPixelOff() |
||||
FilterType.Blur -> if (content.isFilterEnabled(type)) icFilterBlurOn() else icFilterBlurOff() |
||||
} |
||||
} |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
@Composable |
||||
fun Image(content: ContentState) { |
||||
val onUpdate = remember { { content.updateMainImage() } } |
||||
Surface( |
||||
color = DarkGray, |
||||
modifier = Modifier.fillMaxSize() |
||||
) { |
||||
Draggable( |
||||
onUpdate = onUpdate, |
||||
dragHandler = content.drag, |
||||
modifier = Modifier.fillMaxSize() |
||||
) { |
||||
Zoomable( |
||||
onUpdate = onUpdate, |
||||
scaleHandler = content.scale, |
||||
modifier = Modifier.fillMaxSize() |
||||
.onPreviewKeyEvent { |
||||
if (it.type == KeyEventType.KeyUp) { |
||||
when (it.key) { |
||||
Key.DirectionLeft -> { |
||||
content.swipePrevious() |
||||
} |
||||
Key.DirectionRight -> { |
||||
content.swipeNext() |
||||
} |
||||
} |
||||
} |
||||
false |
||||
} |
||||
) { |
||||
Image( |
||||
bitmap = content.getSelectedImage(), |
||||
contentDescription = null, |
||||
contentScale = ContentScale.Fit |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,250 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.* |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.Card |
||||
import androidx.compose.material.Divider |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TopAppBar |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.painter.BitmapPainter |
||||
import androidx.compose.ui.graphics.RectangleShape |
||||
import androidx.compose.ui.graphics.toComposeImageBitmap |
||||
import androidx.compose.ui.layout.ContentScale |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.ResString |
||||
import example.imageviewer.model.AppState |
||||
import example.imageviewer.model.ContentState |
||||
import example.imageviewer.model.Picture |
||||
import example.imageviewer.model.ScreenType |
||||
import example.imageviewer.style.DarkGray |
||||
import example.imageviewer.style.DarkGreen |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.LightGray |
||||
import example.imageviewer.style.MiniatureColor |
||||
import example.imageviewer.style.MiniatureHoverColor |
||||
import example.imageviewer.style.TranslucentBlack |
||||
import example.imageviewer.style.TranslucentWhite |
||||
import example.imageviewer.style.Transparent |
||||
import example.imageviewer.style.icDots |
||||
import example.imageviewer.style.icEmpty |
||||
import example.imageviewer.style.icRefresh |
||||
import example.imageviewer.utils.toByteArray |
||||
|
||||
@Composable |
||||
fun MainScreen(content: ContentState) { |
||||
Column { |
||||
TopContent(content) |
||||
ScrollableArea(content) |
||||
} |
||||
if (!content.isContentReady()) { |
||||
LoadingScreen(ResString.loading) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun TopContent(content: ContentState) { |
||||
TitleBar(text = ResString.appName, content = content) |
||||
PreviewImage(content) |
||||
Spacer(modifier = Modifier.height(10.dp)) |
||||
Divider() |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
} |
||||
|
||||
@Composable |
||||
fun TitleBar(text: String, content: ContentState) { |
||||
val interactionSource = remember { MutableInteractionSource() } |
||||
val refreshButtonHover by interactionSource.collectIsHoveredAsState() |
||||
TopAppBar( |
||||
backgroundColor = DarkGreen, |
||||
title = { |
||||
Row(Modifier.height(50.dp)) { |
||||
Text( |
||||
text, |
||||
color = Foreground, |
||||
modifier = Modifier.weight(1f).align(Alignment.CenterVertically) |
||||
) |
||||
Surface( |
||||
color = Transparent, |
||||
modifier = Modifier.padding(end = 20.dp).align(Alignment.CenterVertically), |
||||
shape = CircleShape |
||||
) { |
||||
Tooltip(ResString.refresh) { |
||||
Clickable( |
||||
modifier = Modifier |
||||
.hoverable(interactionSource) |
||||
.background(color = if (refreshButtonHover) TranslucentBlack else Transparent), |
||||
onClick = { |
||||
if (content.isContentReady()) { |
||||
content.refresh() |
||||
} |
||||
} |
||||
) { |
||||
Image( |
||||
icRefresh(), |
||||
contentDescription = null, |
||||
modifier = Modifier.size(35.dp) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
|
||||
@Composable |
||||
fun PreviewImage(content: ContentState) { |
||||
Clickable( |
||||
modifier = Modifier.background(color = DarkGray), |
||||
onClick = { |
||||
AppState.screenState(ScreenType.FullscreenImage) |
||||
} |
||||
) { |
||||
Card( |
||||
backgroundColor = Transparent, |
||||
modifier = Modifier.height(250.dp), |
||||
shape = RectangleShape, |
||||
elevation = 1.dp |
||||
) { |
||||
Image( |
||||
if (content.isMainImageEmpty()) |
||||
icEmpty() |
||||
else |
||||
BitmapPainter(content.getSelectedImage()), |
||||
contentDescription = null, |
||||
modifier = Modifier |
||||
.fillMaxWidth().padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 5.dp), |
||||
contentScale = ContentScale.Fit |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Miniature( |
||||
picture: Picture, |
||||
content: ContentState |
||||
) { |
||||
val cardHoverInteractionSource = remember { MutableInteractionSource() } |
||||
val cardHover by cardHoverInteractionSource.collectIsHoveredAsState() |
||||
val infoButtonInteractionSource = remember { MutableInteractionSource() } |
||||
val infoButtonHover by infoButtonInteractionSource.collectIsHoveredAsState() |
||||
Card( |
||||
backgroundColor = if (cardHover) MiniatureHoverColor else MiniatureColor, |
||||
modifier = Modifier.padding(start = 10.dp, end = 18.dp).height(70.dp) |
||||
.fillMaxWidth() |
||||
.hoverable(cardHoverInteractionSource) |
||||
.clickable { |
||||
content.setMainImage(picture) |
||||
}, |
||||
shape = RectangleShape |
||||
) { |
||||
Row(modifier = Modifier.padding(end = 30.dp)) { |
||||
Clickable( |
||||
onClick = { |
||||
content.fullscreen(picture) |
||||
} |
||||
) { |
||||
Image( |
||||
org.jetbrains.skia.Image.makeFromEncoded( |
||||
toByteArray(picture.image) |
||||
).toComposeImageBitmap(), |
||||
contentDescription = null, |
||||
modifier = Modifier.height(70.dp) |
||||
.width(90.dp) |
||||
.padding(start = 1.dp, top = 1.dp, end = 1.dp, bottom = 1.dp), |
||||
contentScale = ContentScale.Crop |
||||
) |
||||
} |
||||
Text( |
||||
text = picture.name, |
||||
color = Foreground, |
||||
modifier = Modifier |
||||
.weight(1f) |
||||
.align(Alignment.CenterVertically) |
||||
.padding(start = 16.dp), |
||||
style = MaterialTheme.typography.body1 |
||||
) |
||||
|
||||
Clickable( |
||||
modifier = Modifier.height(70.dp) |
||||
.width(30.dp) |
||||
.hoverable(infoButtonInteractionSource) |
||||
.background(color = if (infoButtonHover) TranslucentWhite else Transparent), |
||||
onClick = { |
||||
showPopUpMessage( |
||||
"${ResString.picture} " + |
||||
"${picture.name} \n" + |
||||
"${ResString.size} " + |
||||
"${picture.width}x${picture.height} " + |
||||
"${ResString.pixels}" |
||||
) |
||||
} |
||||
) { |
||||
Image( |
||||
icDots(), |
||||
contentDescription = null, |
||||
modifier = Modifier.height(70.dp) |
||||
.width(30.dp) |
||||
.padding(start = 1.dp, top = 25.dp, end = 1.dp, bottom = 25.dp), |
||||
contentScale = ContentScale.FillHeight |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun ScrollableArea(content: ContentState) { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize() |
||||
.padding(end = 8.dp) |
||||
) { |
||||
val stateVertical = rememberScrollState(0) |
||||
Column(modifier = Modifier.verticalScroll(stateVertical)) { |
||||
var index = 1 |
||||
Column { |
||||
for (picture in content.getMiniatures()) { |
||||
Miniature( |
||||
picture = picture, |
||||
content = content |
||||
) |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
index++ |
||||
} |
||||
} |
||||
} |
||||
VerticalScrollbar( |
||||
adapter = rememberScrollbarAdapter(stateVertical), |
||||
modifier = Modifier.align(Alignment.CenterEnd) |
||||
.fillMaxHeight() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Divider() { |
||||
Divider( |
||||
color = LightGray, |
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp) |
||||
) |
||||
} |
@ -0,0 +1,59 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import example.imageviewer.style.Foreground |
||||
import example.imageviewer.style.ToastBackground |
||||
import kotlinx.coroutines.delay |
||||
|
||||
enum class ToastDuration(val value: Int) { |
||||
Short(1000), Long(3000) |
||||
} |
||||
|
||||
private var isShown: Boolean = false |
||||
|
||||
@Composable |
||||
fun Toast( |
||||
text: String, |
||||
visibility: MutableState<Boolean> = mutableStateOf(false), |
||||
duration: ToastDuration = ToastDuration.Long |
||||
) { |
||||
if (isShown) { |
||||
return |
||||
} |
||||
|
||||
if (visibility.value) { |
||||
isShown = true |
||||
Box( |
||||
modifier = Modifier.fillMaxSize().padding(bottom = 20.dp), |
||||
contentAlignment = Alignment.BottomCenter |
||||
) { |
||||
Surface( |
||||
modifier = Modifier.size(300.dp, 70.dp), |
||||
color = ToastBackground, |
||||
shape = RoundedCornerShape(4.dp) |
||||
) { |
||||
Box(contentAlignment = Alignment.Center) { |
||||
Text( |
||||
text = text, |
||||
color = Foreground |
||||
) |
||||
} |
||||
LaunchedEffect(Unit) { |
||||
delay(duration.value.toLong()) |
||||
isShown = false |
||||
visibility.value = false |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,38 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.BoxWithTooltip |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.TooltipArea |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
@Composable |
||||
fun Tooltip( |
||||
text: String = "Tooltip", |
||||
content: @Composable () -> Unit |
||||
) { |
||||
TooltipArea( |
||||
tooltip = { |
||||
Surface( |
||||
color = Color(210, 210, 210), |
||||
shape = RoundedCornerShape(4.dp) |
||||
) { |
||||
Text( |
||||
text = text, |
||||
modifier = Modifier.padding(10.dp), |
||||
style = MaterialTheme.typography.caption |
||||
) |
||||
} |
||||
} |
||||
) { |
||||
content() |
||||
} |
||||
} |
@ -0,0 +1,67 @@
|
||||
package example.imageviewer.view |
||||
|
||||
import androidx.compose.foundation.focusable |
||||
import androidx.compose.foundation.gestures.detectTapGestures |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.DisposableEffect |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.focus.FocusRequester |
||||
import androidx.compose.ui.focus.focusRequester |
||||
import androidx.compose.ui.input.key.Key |
||||
import androidx.compose.ui.input.key.key |
||||
import androidx.compose.ui.input.key.type |
||||
import androidx.compose.ui.input.key.KeyEventType |
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent |
||||
import androidx.compose.ui.input.pointer.pointerInput |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import example.imageviewer.style.Transparent |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
@Composable |
||||
fun Zoomable( |
||||
scaleHandler: ScaleHandler, |
||||
modifier: Modifier = Modifier, |
||||
onUpdate: (() -> Unit)? = null, |
||||
children: @Composable() () -> Unit |
||||
) { |
||||
val focusRequester = FocusRequester() |
||||
|
||||
Surface( |
||||
color = Transparent, |
||||
modifier = modifier.onPreviewKeyEvent { |
||||
if (it.type == KeyEventType.KeyUp) { |
||||
when (it.key) { |
||||
Key.I -> { |
||||
scaleHandler.onScale(1.2f) |
||||
onUpdate?.invoke() |
||||
} |
||||
Key.O -> { |
||||
scaleHandler.onScale(0.8f) |
||||
onUpdate?.invoke() |
||||
} |
||||
Key.R -> { |
||||
scaleHandler.reset() |
||||
onUpdate?.invoke() |
||||
} |
||||
} |
||||
} |
||||
false |
||||
} |
||||
.focusRequester(focusRequester) |
||||
.focusable() |
||||
.pointerInput(Unit) { |
||||
detectTapGestures(onDoubleTap = { scaleHandler.reset() }) { |
||||
focusRequester.requestFocus() |
||||
} |
||||
} |
||||
) { |
||||
children() |
||||
} |
||||
|
||||
DisposableEffect(Unit) { |
||||
focusRequester.requestFocus() |
||||
onDispose { } |
||||
} |
||||
} |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 11 KiB |