@ -0,0 +1,5 @@ |
|||||||
|
<resources> |
||||||
|
<style name="Theme.ImageViewer" parent="Theme.AppCompat.Light.NoActionBar"> |
||||||
|
<item name="android:windowFullscreen">true</item> |
||||||
|
</style> |
||||||
|
</resources> |
@ -0,0 +1,2 @@ |
|||||||
|
# set MAPS_API_KEY at local.properties |
||||||
|
MAPS_API_KEY=STUB_FOR_GOOGLE_MAPS_API_KEY |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 421 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -1,2 +1,7 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||||
<manifest package="example.imageviewer.shared"/> |
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||||
|
package="example.imageviewer.shared"> |
||||||
|
<uses-permission android:name="android.permission.CAMERA" /> |
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
||||||
|
</manifest> |
@ -0,0 +1,63 @@ |
|||||||
|
package example.imageviewer |
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList |
||||||
|
import androidx.compose.ui.graphics.ImageBitmap |
||||||
|
import androidx.compose.ui.graphics.asAndroidBitmap |
||||||
|
import androidx.compose.ui.graphics.asImageBitmap |
||||||
|
import androidx.core.graphics.scale |
||||||
|
import example.imageviewer.model.PictureData |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
|
||||||
|
private const val maxStorableImageSizePx = 2000 |
||||||
|
private const val storableThumbnailSizePx = 200 |
||||||
|
|
||||||
|
class AndroidImageStorage( |
||||||
|
private val pictures: SnapshotStateList<PictureData>, |
||||||
|
private val ioScope: CoroutineScope |
||||||
|
) : ImageStorage { |
||||||
|
val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>() |
||||||
|
val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>() |
||||||
|
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) { |
||||||
|
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) { |
||||||
|
return |
||||||
|
} |
||||||
|
ioScope.launch { |
||||||
|
val androidBitmap = image.imageBitmap.asAndroidBitmap() |
||||||
|
|
||||||
|
val targetScale = maxOf( |
||||||
|
maxStorableImageSizePx.toFloat() / androidBitmap.width, |
||||||
|
maxStorableImageSizePx.toFloat() / androidBitmap.height |
||||||
|
) |
||||||
|
largeImages[picture] = |
||||||
|
if (targetScale < 1.0) { |
||||||
|
androidBitmap.scale( |
||||||
|
width = (androidBitmap.width * targetScale).toInt(), |
||||||
|
height = (androidBitmap.height * targetScale).toInt() |
||||||
|
).asImageBitmap() |
||||||
|
} else { |
||||||
|
image.imageBitmap |
||||||
|
} |
||||||
|
|
||||||
|
val targetThumbnailScale = maxOf( |
||||||
|
storableThumbnailSizePx.toFloat() / androidBitmap.width, |
||||||
|
storableThumbnailSizePx.toFloat() / androidBitmap.height |
||||||
|
) |
||||||
|
thumbnails[picture] = androidBitmap.scale( |
||||||
|
width = (androidBitmap.width * targetThumbnailScale).toInt(), |
||||||
|
height = (androidBitmap.height * targetThumbnailScale).toInt() |
||||||
|
).asImageBitmap() |
||||||
|
|
||||||
|
pictures.add(0, picture) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap { |
||||||
|
return thumbnails[picture]!! |
||||||
|
} |
||||||
|
|
||||||
|
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap { |
||||||
|
return largeImages[picture]!! |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
package example.imageviewer |
||||||
|
|
||||||
|
actual fun getCurrentLanguage(): AvailableLanguages = AvailableLanguages.EN |
@ -1,14 +0,0 @@ |
|||||||
package example.imageviewer.model.filtration |
|
||||||
|
|
||||||
import android.content.Context |
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap |
|
||||||
import androidx.compose.ui.graphics.asImageBitmap |
|
||||||
import example.imageviewer.core.BitmapFilter |
|
||||||
import example.imageviewer.utils.applyBlurFilter |
|
||||||
|
|
||||||
class BlurFilter(private val context: Context) : BitmapFilter { |
|
||||||
|
|
||||||
override fun apply(bitmap: ImageBitmap): ImageBitmap = |
|
||||||
applyBlurFilter(bitmap.asAndroidBitmap(), context).asImageBitmap() |
|
||||||
} |
|
@ -1,13 +0,0 @@ |
|||||||
package example.imageviewer.model.filtration |
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap |
|
||||||
import androidx.compose.ui.graphics.asImageBitmap |
|
||||||
import example.imageviewer.core.BitmapFilter |
|
||||||
import example.imageviewer.utils.applyGrayScaleFilter |
|
||||||
|
|
||||||
class GrayScaleFilter : BitmapFilter { |
|
||||||
|
|
||||||
override fun apply(bitmap: ImageBitmap): ImageBitmap = |
|
||||||
applyGrayScaleFilter(bitmap.asAndroidBitmap()).asImageBitmap() |
|
||||||
} |
|
@ -1,12 +0,0 @@ |
|||||||
package example.imageviewer.model.filtration |
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap |
|
||||||
import androidx.compose.ui.graphics.asImageBitmap |
|
||||||
import example.imageviewer.core.BitmapFilter |
|
||||||
import example.imageviewer.utils.applyPixelFilter |
|
||||||
|
|
||||||
class PixelFilter : BitmapFilter { |
|
||||||
override fun apply(bitmap: ImageBitmap): ImageBitmap = |
|
||||||
applyPixelFilter(bitmap.asAndroidBitmap()).asImageBitmap() |
|
||||||
} |
|
@ -1,7 +0,0 @@ |
|||||||
package example.imageviewer.utils |
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable |
|
||||||
import androidx.compose.runtime.rememberCoroutineScope |
|
||||||
import kotlinx.coroutines.Dispatchers |
|
||||||
|
|
||||||
actual val ioDispatcher = Dispatchers.IO |
|
@ -1,21 +1,155 @@ |
|||||||
package example.imageviewer.view |
package example.imageviewer.view |
||||||
|
|
||||||
import androidx.compose.foundation.background |
import android.annotation.SuppressLint |
||||||
|
import android.location.Location |
||||||
|
import androidx.camera.core.CameraSelector |
||||||
|
import androidx.camera.core.ImageCapture |
||||||
|
import androidx.camera.core.ImageCapture.OnImageCapturedCallback |
||||||
|
import androidx.camera.core.ImageProxy |
||||||
|
import androidx.camera.core.Preview |
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider |
||||||
|
import androidx.camera.view.PreviewView |
||||||
import androidx.compose.foundation.layout.Box |
import androidx.compose.foundation.layout.Box |
||||||
import androidx.compose.foundation.layout.fillMaxSize |
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.material.Button |
||||||
|
import androidx.compose.material3.CircularProgressIndicator |
||||||
import androidx.compose.material3.Text |
import androidx.compose.material3.Text |
||||||
import androidx.compose.runtime.Composable |
import androidx.compose.runtime.* |
||||||
import androidx.compose.ui.Alignment |
import androidx.compose.ui.Alignment |
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import androidx.compose.ui.graphics.Color |
import androidx.compose.ui.graphics.Color |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.viewinterop.AndroidView |
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi |
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState |
||||||
|
import com.google.android.gms.location.CurrentLocationRequest |
||||||
|
import com.google.android.gms.location.LocationServices |
||||||
|
import com.google.android.gms.tasks.Task |
||||||
|
import example.imageviewer.* |
||||||
|
import example.imageviewer.model.GpsPosition |
||||||
|
import example.imageviewer.model.PictureData |
||||||
|
import example.imageviewer.model.createCameraPictureData |
||||||
|
import java.nio.ByteBuffer |
||||||
|
import java.util.* |
||||||
|
import java.util.concurrent.Executors |
||||||
|
import kotlin.coroutines.resume |
||||||
|
import kotlin.coroutines.suspendCoroutine |
||||||
|
|
||||||
|
private val executor = Executors.newSingleThreadExecutor() |
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class) |
||||||
|
@Composable |
||||||
|
internal actual fun CameraView( |
||||||
|
modifier: Modifier, |
||||||
|
onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit |
||||||
|
) { |
||||||
|
val cameraPermissionState = rememberMultiplePermissionsState( |
||||||
|
listOf( |
||||||
|
android.Manifest.permission.CAMERA, |
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION, |
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION, |
||||||
|
) |
||||||
|
) |
||||||
|
if (cameraPermissionState.allPermissionsGranted) { |
||||||
|
CameraWithGrantedPermission(modifier, onCapture) |
||||||
|
} else { |
||||||
|
LaunchedEffect(Unit) { |
||||||
|
cameraPermissionState.launchMultiplePermissionRequest() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@SuppressLint("MissingPermission") |
||||||
@Composable |
@Composable |
||||||
internal actual fun CameraView(modifier: Modifier) { |
private fun CameraWithGrantedPermission( |
||||||
Box(Modifier.fillMaxSize().background(Color.Black)) { |
modifier: Modifier, |
||||||
Text( |
onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit |
||||||
text = "Camera is not available on Android for now.", |
) { |
||||||
color = Color.White, |
val context = LocalContext.current |
||||||
modifier = Modifier.align(Alignment.Center) |
val lifecycleOwner = LocalLifecycleOwner.current |
||||||
|
|
||||||
|
val preview = Preview.Builder().build() |
||||||
|
val previewView = remember { PreviewView(context) } |
||||||
|
val imageCapture: ImageCapture = remember { ImageCapture.Builder().build() } |
||||||
|
val cameraSelector = CameraSelector.Builder() |
||||||
|
.requireLensFacing(CameraSelector.LENS_FACING_BACK) |
||||||
|
.build() |
||||||
|
|
||||||
|
LaunchedEffect(Unit) { |
||||||
|
val cameraProvider = suspendCoroutine<ProcessCameraProvider> { continuation -> |
||||||
|
ProcessCameraProvider.getInstance(context).also { cameraProvider -> |
||||||
|
cameraProvider.addListener({ |
||||||
|
continuation.resume(cameraProvider.get()) |
||||||
|
}, executor) |
||||||
|
} |
||||||
|
} |
||||||
|
cameraProvider.unbindAll() |
||||||
|
cameraProvider.bindToLifecycle( |
||||||
|
lifecycleOwner, |
||||||
|
cameraSelector, |
||||||
|
preview, |
||||||
|
imageCapture |
||||||
) |
) |
||||||
|
preview.setSurfaceProvider(previewView.surfaceProvider) |
||||||
|
} |
||||||
|
val nameAndDescription = createNewPhotoNameAndDescription() |
||||||
|
var capturePhotoStarted by remember { mutableStateOf(false) } |
||||||
|
Box(contentAlignment = Alignment.BottomCenter, modifier = modifier) { |
||||||
|
AndroidView({ previewView }, modifier = Modifier.fillMaxSize()) |
||||||
|
Button( |
||||||
|
enabled = !capturePhotoStarted, |
||||||
|
onClick = { |
||||||
|
capturePhotoStarted = true |
||||||
|
imageCapture.takePicture(executor, object : OnImageCapturedCallback() { |
||||||
|
override fun onCaptureSuccess(image: ImageProxy) { |
||||||
|
val byteArray: ByteArray = image.planes[0].buffer.toByteArray() |
||||||
|
val imageBitmap = byteArray.toImageBitmap() |
||||||
|
image.close() |
||||||
|
fun sendToStorage(gpsPosition: GpsPosition) { |
||||||
|
onCapture( |
||||||
|
createCameraPictureData( |
||||||
|
name = nameAndDescription.name, |
||||||
|
description = nameAndDescription.description, |
||||||
|
gps = gpsPosition |
||||||
|
), |
||||||
|
AndroidStorableImage(imageBitmap) |
||||||
|
) |
||||||
|
capturePhotoStarted = false |
||||||
|
} |
||||||
|
|
||||||
|
val lastLocation: Task<Location> = |
||||||
|
LocationServices.getFusedLocationProviderClient(context) |
||||||
|
.getCurrentLocation( |
||||||
|
CurrentLocationRequest.Builder().build(), |
||||||
|
null |
||||||
|
) |
||||||
|
lastLocation.addOnSuccessListener { |
||||||
|
sendToStorage(GpsPosition(it.latitude, it.longitude)) |
||||||
|
} |
||||||
|
lastLocation.addOnFailureListener { |
||||||
|
sendToStorage(GpsPosition(0.0, 0.0)) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
}) { |
||||||
|
Text(LocalLocalization.current.takePhoto, color = Color.White) |
||||||
|
} |
||||||
|
if (capturePhotoStarted) { |
||||||
|
CircularProgressIndicator( |
||||||
|
modifier = Modifier.size(80.dp).align(Alignment.Center), |
||||||
|
color = Color.White.copy(alpha = 0.7f), |
||||||
|
strokeWidth = 8.dp, |
||||||
|
) |
||||||
|
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
|
private fun ByteBuffer.toByteArray(): ByteArray { |
||||||
|
rewind() // Rewind the buffer to zero |
||||||
|
val data = ByteArray(remaining()) |
||||||
|
get(data) // Copy the buffer into a byte array |
||||||
|
return data // Return the byte array |
||||||
|
} |
||||||
|
@ -1,19 +1,21 @@ |
|||||||
package example.imageviewer.view |
package example.imageviewer.view |
||||||
|
|
||||||
import androidx.compose.foundation.Image |
|
||||||
import androidx.compose.runtime.Composable |
import androidx.compose.runtime.Composable |
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import androidx.compose.ui.layout.ContentScale |
import com.google.android.gms.maps.model.CameraPosition |
||||||
import org.jetbrains.compose.resources.ExperimentalResourceApi |
import com.google.android.gms.maps.model.LatLng |
||||||
import org.jetbrains.compose.resources.painterResource |
import com.google.maps.android.compose.GoogleMap |
||||||
|
import com.google.maps.android.compose.rememberCameraPositionState |
||||||
|
import example.imageviewer.model.GpsPosition |
||||||
|
|
||||||
@OptIn(ExperimentalResourceApi::class) |
|
||||||
@Composable |
@Composable |
||||||
internal actual fun LocationVisualizer(modifier: Modifier) { |
internal actual fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) { |
||||||
Image( |
val currentLocation = LatLng(gps.latitude, gps.longitude) |
||||||
painter = painterResource("dummy_map.png"), |
val cameraPositionState = rememberCameraPositionState { |
||||||
contentDescription = "Map", |
position = CameraPosition.fromLatLngZoom(currentLocation, 10f) |
||||||
contentScale = ContentScale.Crop, |
} |
||||||
modifier = modifier |
GoogleMap( |
||||||
|
modifier = modifier, |
||||||
|
cameraPositionState = cameraPositionState |
||||||
) |
) |
||||||
} |
} |
||||||
|
@ -1,9 +1,13 @@ |
|||||||
package example.imageviewer.view |
package example.imageviewer.view |
||||||
|
|
||||||
import androidx.compose.runtime.Composable |
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
|
||||||
@Composable |
@Composable |
||||||
internal actual fun Tooltip(text: String, content: @Composable () -> Unit) { |
internal actual fun Tooltip( |
||||||
|
text: String, |
||||||
|
content: @Composable () -> Unit |
||||||
|
) { |
||||||
// No Tooltip for Android |
// No Tooltip for Android |
||||||
content() |
content() |
||||||
} |
} |
||||||
|
@ -0,0 +1,11 @@ |
|||||||
|
package example.imageviewer.view |
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxScope |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import example.imageviewer.model.ScalableState |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) { |
||||||
|
// No need for additional ZoomControllerView for Android |
||||||
|
} |
@ -1,17 +1,4 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||||
<resources> |
<resources> |
||||||
<string name="app_name">ImageViewer</string> |
<string name="app_name">ImageViewer</string> |
||||||
<string name="loading">Bilder werden geladen...</string> |
|
||||||
<string name="repo_empty">Bildverzeichnis ist leer.</string> |
|
||||||
<string name="no_internet">Kein Internetzugriff.</string> |
|
||||||
<string name="repo_invalid">Bildverzeichnis beschädigt oder leer.</string> |
|
||||||
<string name="refresh_unavailable">Kann Bilder nicht aktualisieren.</string> |
|
||||||
<string name="load_image_unavailable">Kann volles Bild nicht laden.</string> |
|
||||||
<string name="last_image">Dies ist das letzte Bild.</string> |
|
||||||
<string name="first_image">Dies ist das erste Bild.</string> |
|
||||||
<string name="picture">Bild:</string> |
|
||||||
<string name="size">Abmessungen:</string> |
|
||||||
<string name="pixels">Pixel.</string> |
|
||||||
<string name="back">Zurück</string> |
|
||||||
<string name="refresh">Aktualisieren</string> |
|
||||||
</resources> |
</resources> |
||||||
|
@ -1,16 +1,3 @@ |
|||||||
<resources> |
<resources> |
||||||
<string name="app_name">My Memories</string> |
<string name="app_name">My Memories</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> |
|
||||||
<string name="back">back</string> |
|
||||||
<string name="refresh">Refresh</string> |
|
||||||
</resources> |
</resources> |
@ -1,80 +1,99 @@ |
|||||||
package example.imageviewer |
package example.imageviewer |
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf |
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList |
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf |
||||||
import androidx.compose.ui.graphics.ImageBitmap |
import androidx.compose.ui.graphics.ImageBitmap |
||||||
import example.imageviewer.core.BitmapFilter |
import example.imageviewer.model.* |
||||||
import example.imageviewer.core.FilterType |
import kotlinx.coroutines.flow.Flow |
||||||
import example.imageviewer.model.ContentRepository |
import kotlinx.coroutines.flow.emptyFlow |
||||||
import example.imageviewer.model.Picture |
import org.jetbrains.compose.resources.ExperimentalResourceApi |
||||||
import example.imageviewer.model.name |
import org.jetbrains.compose.resources.resource |
||||||
import io.ktor.client.* |
|
||||||
import kotlinx.coroutines.CoroutineScope |
@OptIn(ExperimentalResourceApi::class) |
||||||
import kotlinx.serialization.json.Json |
abstract class Dependencies { |
||||||
|
abstract val notification: Notification |
||||||
interface Dependencies { |
abstract val imageStorage: ImageStorage |
||||||
val httpClient: HttpClient |
val pictures: SnapshotStateList<PictureData> = mutableStateListOf(*resourcePictures) |
||||||
val ioScope: CoroutineScope |
open val externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow() |
||||||
fun getFilter(type: FilterType): BitmapFilter |
val localization: Localization = getCurrentLocalization() |
||||||
val localization: Localization |
val imageProvider: ImageProvider = object : ImageProvider { |
||||||
val imageRepository: ContentRepository<ImageBitmap> |
override suspend fun getImage(picture: PictureData): ImageBitmap = when (picture) { |
||||||
val notification: Notification |
is PictureData.Resource -> { |
||||||
val json: Json get() = jsonReader |
resource(picture.resource).readBytes().toImageBitmap() |
||||||
|
} |
||||||
|
|
||||||
|
is PictureData.Camera -> { |
||||||
|
imageStorage.getImage(picture) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override suspend fun getThumbnail(picture: PictureData): ImageBitmap = when (picture) { |
||||||
|
is PictureData.Resource -> { |
||||||
|
resource(picture.thumbnailResource).readBytes().toImageBitmap() |
||||||
|
} |
||||||
|
|
||||||
|
is PictureData.Camera -> { |
||||||
|
imageStorage.getThumbnail(picture) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
interface Notification { |
interface Notification { |
||||||
fun notifyInvalidRepo() |
fun notifyImageData(picture: PictureData) |
||||||
fun notifyRepoIsEmpty() |
|
||||||
fun notifyNoInternet() |
|
||||||
fun notifyLoadImageUnavailable() |
|
||||||
fun notifyLastImage() |
|
||||||
fun notifyFirstImage() |
|
||||||
fun notifyImageData(picture: Picture) |
|
||||||
fun notifyRefreshUnavailable() |
|
||||||
} |
} |
||||||
|
|
||||||
abstract class PopupNotification(private val localization: Localization) : Notification { |
abstract class PopupNotification(private val localization: Localization) : Notification { |
||||||
abstract fun showPopUpMessage(text: String) |
abstract fun showPopUpMessage(text: String) |
||||||
|
override fun notifyImageData(picture: PictureData) = showPopUpMessage( |
||||||
override fun notifyInvalidRepo() = showPopUpMessage(localization.repoInvalid) |
|
||||||
override fun notifyRepoIsEmpty() = showPopUpMessage(localization.repoEmpty) |
|
||||||
override fun notifyNoInternet() = showPopUpMessage(localization.noInternet) |
|
||||||
override fun notifyLoadImageUnavailable() = |
|
||||||
showPopUpMessage( |
|
||||||
""" |
|
||||||
${localization.noInternet} |
|
||||||
${localization.loadImageUnavailable} |
|
||||||
""".trimIndent() |
|
||||||
) |
|
||||||
|
|
||||||
override fun notifyLastImage() = showPopUpMessage(localization.lastImage) |
|
||||||
override fun notifyFirstImage() = showPopUpMessage(localization.firstImage) |
|
||||||
override fun notifyImageData(picture: Picture) = showPopUpMessage( |
|
||||||
"${localization.picture} ${picture.name}" |
"${localization.picture} ${picture.name}" |
||||||
) |
) |
||||||
|
|
||||||
override fun notifyRefreshUnavailable() = showPopUpMessage( |
|
||||||
""" |
|
||||||
${localization.noInternet} |
|
||||||
${localization.refreshUnavailable} |
|
||||||
""".trimIndent() |
|
||||||
) |
|
||||||
} |
} |
||||||
|
|
||||||
interface Localization { |
interface Localization { |
||||||
val back: String |
|
||||||
val appName: String |
val appName: String |
||||||
val loading: String |
val back: String |
||||||
val repoInvalid: String |
|
||||||
val repoEmpty: String |
|
||||||
val noInternet: String |
|
||||||
val loadImageUnavailable: String |
|
||||||
val lastImage: String |
|
||||||
val firstImage: String |
|
||||||
val picture: String |
val picture: String |
||||||
val size: String |
val takePhoto: String |
||||||
val pixels: String |
val addPhoto: String |
||||||
val refreshUnavailable: String |
val kotlinConfName: String |
||||||
|
val kotlinConfDescription: String |
||||||
|
val newPhotoName: String |
||||||
|
val newPhotoDescription: String |
||||||
|
} |
||||||
|
|
||||||
|
interface ImageProvider { |
||||||
|
suspend fun getImage(picture: PictureData): ImageBitmap |
||||||
|
suspend fun getThumbnail(picture: PictureData): ImageBitmap |
||||||
|
} |
||||||
|
|
||||||
|
interface ImageStorage { |
||||||
|
fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) |
||||||
|
suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap |
||||||
|
suspend fun getImage(picture: PictureData.Camera): ImageBitmap |
||||||
|
} |
||||||
|
|
||||||
|
internal val LocalLocalization = staticCompositionLocalOf<Localization> { |
||||||
|
noLocalProvidedFor("LocalLocalization") |
||||||
|
} |
||||||
|
|
||||||
|
internal val LocalNotification = staticCompositionLocalOf<Notification> { |
||||||
|
noLocalProvidedFor("LocalNotification") |
||||||
|
} |
||||||
|
|
||||||
|
internal val LocalImageProvider = staticCompositionLocalOf<ImageProvider> { |
||||||
|
noLocalProvidedFor("LocalImageProvider") |
||||||
|
} |
||||||
|
|
||||||
|
internal val LocalImageStorage = staticCompositionLocalOf<ImageStorage> { |
||||||
|
noLocalProvidedFor("LocalImageStorage") |
||||||
|
} |
||||||
|
|
||||||
|
internal val LocalInternalEvents = staticCompositionLocalOf<Flow<ExternalImageViewerEvent>> { |
||||||
|
noLocalProvidedFor("LocalInternalEvents") |
||||||
} |
} |
||||||
|
|
||||||
private val jsonReader: Json = Json { |
private fun noLocalProvidedFor(name: String): Nothing { |
||||||
ignoreUnknownKeys = true |
error("CompositionLocal $name not present") |
||||||
} |
} |
||||||
|
@ -0,0 +1,43 @@ |
|||||||
|
package example.imageviewer |
||||||
|
|
||||||
|
enum class AvailableLanguages { |
||||||
|
DE, |
||||||
|
EN; |
||||||
|
} |
||||||
|
|
||||||
|
expect fun getCurrentLanguage(): AvailableLanguages |
||||||
|
|
||||||
|
private object EnglishLocalization : Localization { |
||||||
|
override val appName = "My Memories" |
||||||
|
override val picture = "Picture:" |
||||||
|
override val back = "Back" |
||||||
|
override val takePhoto = "Take a photo 📸" |
||||||
|
override val addPhoto = "Add a photo" |
||||||
|
override val kotlinConfName = "KotlinConf 2023 🎉" |
||||||
|
override val kotlinConfDescription = """ |
||||||
|
This photo was taken during KotlinConf 2023! 🎊 |
||||||
|
Have a fun with Kotlin and Compose Multiplatform 🥳 |
||||||
|
""".trimIndent() |
||||||
|
override val newPhotoName = "New Memory" |
||||||
|
override val newPhotoDescription = "May amazing things happen to you! 🙂" |
||||||
|
} |
||||||
|
|
||||||
|
private object DeutschLocalization : Localization { |
||||||
|
override val appName = "Meine Erinnerungen" |
||||||
|
override val picture = "Bild:" |
||||||
|
override val back = "Zurück" |
||||||
|
override val takePhoto = "Mach ein Foto 📸" |
||||||
|
override val addPhoto = "Füge ein Foto hinzu" |
||||||
|
override val kotlinConfName = "KotlinConf 2023 🎉" |
||||||
|
override val kotlinConfDescription = """ |
||||||
|
This photo was taken during KotlinConf 2023! 🎊 |
||||||
|
Have a fun with Kotlin and Compose Multiplatform 🥳 |
||||||
|
""".trimIndent() |
||||||
|
override val newPhotoName = "New Memory" |
||||||
|
override val newPhotoDescription = "May amazing things happen to you! 🙂" |
||||||
|
} |
||||||
|
|
||||||
|
fun getCurrentLocalization() = when (getCurrentLanguage()) { |
||||||
|
AvailableLanguages.EN -> EnglishLocalization |
||||||
|
AvailableLanguages.DE -> DeutschLocalization |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package example.imageviewer |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import kotlinx.datetime.* |
||||||
|
|
||||||
|
class NameAndDescription( |
||||||
|
val name: String, |
||||||
|
val description: String, |
||||||
|
) |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun createNewPhotoNameAndDescription(): NameAndDescription { |
||||||
|
val localization = LocalLocalization.current |
||||||
|
return remember { |
||||||
|
|
||||||
|
Clock.System.now().toLocalDateTime(TimeZone.UTC) |
||||||
|
val kotlinConfEndTime = |
||||||
|
LocalDateTime(2023, Month.APRIL, 14, hour = 23, minute = 59).toInstant(TimeZone.UTC) |
||||||
|
|
||||||
|
if (Clock.System.now() < kotlinConfEndTime) { |
||||||
|
NameAndDescription( |
||||||
|
localization.kotlinConfName, |
||||||
|
localization.kotlinConfDescription |
||||||
|
) |
||||||
|
} else { |
||||||
|
NameAndDescription( |
||||||
|
localization.newPhotoName, |
||||||
|
localization.newPhotoDescription |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,213 @@ |
|||||||
|
package example.imageviewer |
||||||
|
|
||||||
|
import example.imageviewer.model.GpsPosition |
||||||
|
import example.imageviewer.model.PictureData |
||||||
|
|
||||||
|
val resourcePictures = arrayOf( |
||||||
|
PictureData.Resource( |
||||||
|
resource = "1.jpg", |
||||||
|
thumbnailResource = "1-thumbnail.jpg", |
||||||
|
name = "Mountain K2", |
||||||
|
description = """ |
||||||
|
K2, at 8,611 meters above sea level, is the second-highest mountain on Earth, after Mount Everest. |
||||||
|
The name K2 is derived from notation used by the Great Trigonometrical Survey of British India. Thomas Montgomerie made the first survey of the Karakoram from Mount Haramukh, some 210 km to the south, and sketched the two most prominent peaks, labelling them K1 and K2, where the K stands for Karakoram. |
||||||
|
Also the new Kotlin compiler is code-named "K2". |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "20 Mar.", |
||||||
|
gps = GpsPosition(35.8825, 76.513333) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "2.jpg", |
||||||
|
thumbnailResource = "2-thumbnail.jpg", |
||||||
|
name = "Kina The Calico", |
||||||
|
description = """ |
||||||
|
This cute kitty 🐱 loves one thing above all: soups and sauces! |
||||||
|
A true connoisseur of all liquid meals, you'll frequently find her lounging by a sunny window and surveying the neighbourhood. |
||||||
|
But only until it's dinner time again, of course! 🍜 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "3 Feb.", |
||||||
|
gps = GpsPosition(48.138018, 11.5737048) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "3.jpg", |
||||||
|
thumbnailResource = "3-thumbnail.jpg", |
||||||
|
name = "Blue City", |
||||||
|
description = """ |
||||||
|
Is a city in northwest Morocco. |
||||||
|
It is the chief town of the province of the same name and is noted for its buildings in shades of blue, for which it is nicknamed the "Blue City". |
||||||
|
Chefchaouen is situated just inland from Tangier and Tétouan. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "12 May.", |
||||||
|
gps = GpsPosition(35.171389, -5.269722) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "4.jpg", |
||||||
|
thumbnailResource = "4-thumbnail.jpg", |
||||||
|
name = "Tokyo Skytree", |
||||||
|
description = """ |
||||||
|
Tokyo Skytree is a broadcasting and observation tower in Sumida, Tokyo. |
||||||
|
It became the tallest structure in Japan in 2010 and reached its full height of 634 meters in March 2011. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "22 Mar.", |
||||||
|
gps = GpsPosition(35.7101, 139.8107) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "5.jpg", |
||||||
|
thumbnailResource = "5-thumbnail.jpg", |
||||||
|
name = "Taranaki", |
||||||
|
description = """ |
||||||
|
Mount Taranaki is a dormant stratovolcano in the Taranaki region on the west coast of New Zealand's North Island. |
||||||
|
At 2,518 metres, it is the second highest mountain in the North Island, after Mount Ruapehu. |
||||||
|
It has a secondary cone, Fanthams Peak, 1,966 metres, on its south side. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "3 May.", |
||||||
|
gps = GpsPosition(-39.296389, 174.064722) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "6.jpg", |
||||||
|
thumbnailResource = "6-thumbnail.jpg", |
||||||
|
name = "Auckland SkyCity", |
||||||
|
description = """ |
||||||
|
SkyCity Casino History |
||||||
|
This kiwi casino is a part of the Sky Tower, a giant resort that was completed in 1997. |
||||||
|
There were many New Zealand casinos at that time and this was in fact the second one ever built in the whole country |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "15 Aug.", |
||||||
|
gps = GpsPosition(-36.846589, 174.760871) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "7.jpg", |
||||||
|
thumbnailResource = "7-thumbnail.jpg", |
||||||
|
name = "Berliner Fernsehturm", |
||||||
|
description = """ |
||||||
|
At 368 meters, the Berlin television tower is the tallest building in Germany and the fifth tallest television tower in Europe. |
||||||
|
The television tower is located in the park at the television tower in Berlin's Mitte district. |
||||||
|
When it was completed in 1969, it was the second highest television tower in the world and, with over a million visitors a year, is one of the ten most popular sights in Germany. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "24 Sep.", |
||||||
|
gps = GpsPosition(52.520833, 13.409444) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "8.jpg", |
||||||
|
thumbnailResource = "8-thumbnail.jpg", |
||||||
|
name = "Hoggar Mountains", |
||||||
|
description = """ |
||||||
|
The Hoggar Mountains are a highland region in the central Sahara in southern Algeria, along the Tropic of Cancer. |
||||||
|
The mountains cover an area of approximately 550,000 km. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "13 Jul.", |
||||||
|
gps = GpsPosition(22.133333, 6.166667) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "9.jpg", |
||||||
|
thumbnailResource = "9-thumbnail.jpg", |
||||||
|
name = "Nakhal Fort", |
||||||
|
description = """ |
||||||
|
Nakhal Fort is a large fortification in Al Batinah Region of Oman. |
||||||
|
It is named after the Wilayah of Nakhal. |
||||||
|
The fort houses a museum, operated by the Ministry of Tourism, which has exhibits of historic guns, and the fort also hosts a weekly goat market. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "20 Aug.", |
||||||
|
gps = GpsPosition(23.395, 57.829) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "10.jpg", |
||||||
|
thumbnailResource = "10-thumbnail.jpg", |
||||||
|
name = "Mountain Ararat", |
||||||
|
description = """ |
||||||
|
Mount Ararat is a snow-capped and dormant compound volcano in the extreme east of Turkey. |
||||||
|
It consists of two major volcanic cones: Greater Ararat and Little Ararat. |
||||||
|
Greater Ararat is the highest peak in Turkey and the Armenian Highland with an elevation of 5,137 m. 🏔 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "12 Apr.", |
||||||
|
gps = GpsPosition(40.169339, 44.488434) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "11.jpg", |
||||||
|
thumbnailResource = "11-thumbnail.jpg", |
||||||
|
name = "Cabo da Roca", |
||||||
|
description = """ |
||||||
|
The view on Cabo da Roca. |
||||||
|
Cabo da Roca or Cape Roca is a cape which forms the westernmost point of the Sintra Mountain Range, of mainland Portugal, of continental Europe, and of the Eurasian landmass. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "3 Jun.", |
||||||
|
gps = GpsPosition(38.789283172, 9.4909725957) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "12.jpg", |
||||||
|
thumbnailResource = "12-thumbnail.jpg", |
||||||
|
name = "Surprised Whiskers 🐱", |
||||||
|
description = """ |
||||||
|
Surprised Whiskers: A Furry Tale. |
||||||
|
The photo captures Whiskers' adorably astonished expression as something unexpected catches his eye. |
||||||
|
The scene masterfully highlights the cat's vibrant fur and mesmerizing gaze, drawing the viewer into the furry tale unfolding before them. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "10 Apr.", |
||||||
|
gps = GpsPosition(52.3560485, 4.9085645) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "13.jpg", |
||||||
|
thumbnailResource = "13-thumbnail.jpg", |
||||||
|
name = "Software Engineering Donut", |
||||||
|
description = """ |
||||||
|
Munich |
||||||
|
During our Introduction to Software Engineering Lectures, the professor would hand out little prizes to students who would solve coding challenges quickly. |
||||||
|
I solved a challenge about software design patterns as the first student of over 800, and got rewarded with this donut in the style of a cookie monster! |
||||||
|
It was really delicious! 😋 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "21 Dec.", |
||||||
|
gps = GpsPosition(48.1764708, 11.4580367) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "14.jpg", |
||||||
|
thumbnailResource = "14-thumbnail.jpg", |
||||||
|
name = "Seligman Police Car.", |
||||||
|
description = """ |
||||||
|
Seligman, USA |
||||||
|
I really enjoy old cars, and historic police cars are no exception! 🚓 |
||||||
|
I stumbled across this one during a roadtrip across the united states in Seligman, a 500-soul town in the middle of the Arizona countryside. |
||||||
|
The extended hood and rounded forms of this car are just delightful to me. Plus, it has the option to go wee-ooo-wee-ooo! 🚨 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "14 Jul.", |
||||||
|
gps = GpsPosition(35.3259364, -112.8553165) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "15.jpg", |
||||||
|
thumbnailResource = "15-thumbnail.jpg", |
||||||
|
name = "Good Luck Charms", |
||||||
|
description = """ |
||||||
|
Munich |
||||||
|
I decided I'd make my office a little bit more homely with trinkets from Tokyo and Las Vegas! 🐱🎰 |
||||||
|
The cat is a variant of a Daruma doll, and is regarded more as a talisman of good luck, which you can never have enough of! |
||||||
|
The dice come from a casino in Las Vegas that shut down, and in traditional fashion, I decided they should show the numbers four and three, since that gives you the lucky number seven. |
||||||
|
These figures are still sitting on my desk, and it makes me really happy to look at them! 👀 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "28 Mar.", |
||||||
|
gps = GpsPosition(48.1458602, 11.5053059) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "16.jpg", |
||||||
|
thumbnailResource = "16-thumbnail.jpg", |
||||||
|
name = "Pong Restaurant", |
||||||
|
description = """ |
||||||
|
Stockholm, Sweden |
||||||
|
This little restaurant caught my eye because of the color scheme they use! 🦩 |
||||||
|
The neon lights illuminating the dark streets of stockholm was a sight to behold, and the fact that only the first and last letter weren't lit up seems almost intentional. |
||||||
|
Also, the dumplings served at that place was delightful! 🥟 |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "25 Jul.", |
||||||
|
gps = GpsPosition(59.3364318, 18.0587228) |
||||||
|
), |
||||||
|
PictureData.Resource( |
||||||
|
resource = "17.jpg", |
||||||
|
thumbnailResource = "17-thumbnail.jpg", |
||||||
|
name = "Loki", |
||||||
|
description = """ |
||||||
|
Meet Loki, my black cat - a furry feline with big, beautiful eyes and an arrogant attitude. |
||||||
|
Just look at that judging gaze - it's clear that Loki demands more food and is disappointed with my efforts so far! |
||||||
|
Despite his round belly and chubby cheeks, Loki exudes a regal and confident aura that's hard to resist. |
||||||
|
""".trimIndent(), |
||||||
|
dateString = "4 Mar.", |
||||||
|
gps = GpsPosition(66.476857055, 25.759675853) |
||||||
|
), |
||||||
|
) |
@ -1,7 +0,0 @@ |
|||||||
package example.imageviewer.core |
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
|
|
||||||
interface BitmapFilter { |
|
||||||
fun apply(bitmap: ImageBitmap): ImageBitmap |
|
||||||
} |
|
@ -0,0 +1,20 @@ |
|||||||
|
package example.imageviewer.filter |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.graphics.ImageBitmap |
||||||
|
|
||||||
|
fun getFilter(type: FilterType): (ImageBitmap, PlatformContext) -> ImageBitmap = |
||||||
|
when (type) { |
||||||
|
FilterType.GrayScale -> ::grayScaleFilter |
||||||
|
FilterType.Pixel -> ::pixelFilter |
||||||
|
FilterType.Blur -> ::blurFilter |
||||||
|
} |
||||||
|
|
||||||
|
expect fun grayScaleFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap |
||||||
|
expect fun pixelFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap |
||||||
|
expect fun blurFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap |
||||||
|
|
||||||
|
expect class PlatformContext |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal expect fun getPlatformContext(): PlatformContext |
@ -1,5 +1,5 @@ |
|||||||
package example.imageviewer.core |
package example.imageviewer.filter |
||||||
|
|
||||||
enum class FilterType { |
enum class FilterType { |
||||||
GrayScale, Pixel, Blur |
GrayScale, Pixel, Blur |
||||||
} |
} |
@ -1,7 +1,5 @@ |
|||||||
package example.imageviewer.model |
package example.imageviewer.model |
||||||
|
|
||||||
const val BASE_URL = "https://raw.githubusercontent.com/JetBrains/compose-jb/master/artwork/imageviewerrepo" |
|
||||||
const val PICTURES_DATA_URL = "$BASE_URL/pictures.json" |
|
||||||
const val MAX_SCALE = 5f |
const val MAX_SCALE = 5f |
||||||
const val MIN_SCALE = 1f |
const val MIN_SCALE = 1f |
||||||
const val TOAST_DURATION = 3000L |
const val TOAST_DURATION = 3000L |
||||||
|
@ -1,23 +0,0 @@ |
|||||||
package example.imageviewer.model |
|
||||||
|
|
||||||
import io.ktor.client.* |
|
||||||
import io.ktor.client.request.* |
|
||||||
import io.ktor.client.statement.* |
|
||||||
|
|
||||||
interface ContentRepository<T> { |
|
||||||
suspend fun loadContent(url: String): T |
|
||||||
} |
|
||||||
|
|
||||||
fun createNetworkRepository(ktorClient: HttpClient) = object : ContentRepository<ByteArray> { |
|
||||||
override suspend fun loadContent(url: String): ByteArray = |
|
||||||
ktorClient.get(urlString = url).readBytes() |
|
||||||
} |
|
||||||
|
|
||||||
fun <A, B> ContentRepository<A>.adapter(transform: (A) -> B): ContentRepository<B> { |
|
||||||
val origin = this |
|
||||||
return object : ContentRepository<B> { |
|
||||||
override suspend fun loadContent(url: String): B { |
|
||||||
return transform(origin.loadContent(url)) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,59 +1,8 @@ |
|||||||
package example.imageviewer.model |
package example.imageviewer.model |
||||||
|
|
||||||
import androidx.compose.foundation.ScrollState |
sealed interface Page |
||||||
import androidx.compose.runtime.getValue |
|
||||||
import androidx.compose.runtime.mutableStateOf |
|
||||||
import androidx.compose.runtime.setValue |
|
||||||
import example.imageviewer.ExternalImageViewerEvent |
|
||||||
import example.imageviewer.view.GalleryStyle |
|
||||||
import kotlinx.coroutines.flow.Flow |
|
||||||
|
|
||||||
sealed class Page |
class MemoryPage(val picture: PictureData) : Page |
||||||
|
class CameraPage : Page |
||||||
class MemoryPage(val galleryId: GalleryId) : Page() { |
class FullScreenPage(val picture: PictureData) : Page |
||||||
val scrollState = ScrollState(0) |
class GalleryPage : Page |
||||||
} |
|
||||||
|
|
||||||
class CameraPage : Page() |
|
||||||
|
|
||||||
class FullScreenPage(val galleryId: GalleryId) : Page() |
|
||||||
|
|
||||||
class GalleryPage( |
|
||||||
val photoGallery: PhotoGallery, |
|
||||||
val externalEvents: Flow<ExternalImageViewerEvent> |
|
||||||
) : Page() { |
|
||||||
var galleryStyle by mutableStateOf(GalleryStyle.SQUARES) |
|
||||||
|
|
||||||
fun toggleGalleryStyle() { |
|
||||||
galleryStyle = |
|
||||||
if (galleryStyle == GalleryStyle.SQUARES) GalleryStyle.LIST else GalleryStyle.SQUARES |
|
||||||
} |
|
||||||
|
|
||||||
var currentPictureIndex by mutableStateOf(0) |
|
||||||
|
|
||||||
val picture get(): Picture? = photoGallery.galleryStateFlow.value.getOrNull(currentPictureIndex)?.picture |
|
||||||
|
|
||||||
val galleryEntry: GalleryEntryWithMetadata? |
|
||||||
get() = photoGallery.galleryStateFlow.value.getOrNull( |
|
||||||
currentPictureIndex |
|
||||||
) |
|
||||||
|
|
||||||
val pictureId |
|
||||||
get(): GalleryId? = photoGallery.galleryStateFlow.value.getOrNull( |
|
||||||
currentPictureIndex |
|
||||||
)?.id |
|
||||||
|
|
||||||
fun nextImage() { |
|
||||||
currentPictureIndex = |
|
||||||
(currentPictureIndex + 1).mod(photoGallery.galleryStateFlow.value.lastIndex) |
|
||||||
} |
|
||||||
|
|
||||||
fun previousImage() { |
|
||||||
currentPictureIndex = |
|
||||||
(currentPictureIndex - 1).mod(photoGallery.galleryStateFlow.value.lastIndex) |
|
||||||
} |
|
||||||
|
|
||||||
fun selectPicture(galleryId: GalleryId) { |
|
||||||
currentPictureIndex = photoGallery.galleryStateFlow.value.indexOfFirst { it.id == galleryId } |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,67 +0,0 @@ |
|||||||
package example.imageviewer.model |
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import example.imageviewer.Dependencies |
|
||||||
import io.ktor.client.request.get |
|
||||||
import io.ktor.client.statement.bodyAsText |
|
||||||
import kotlinx.coroutines.CancellationException |
|
||||||
import kotlinx.coroutines.async |
|
||||||
import kotlinx.coroutines.awaitAll |
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow |
|
||||||
import kotlinx.coroutines.flow.StateFlow |
|
||||||
import kotlinx.coroutines.launch |
|
||||||
import kotlinx.serialization.builtins.ListSerializer |
|
||||||
import kotlin.jvm.JvmInline |
|
||||||
|
|
||||||
|
|
||||||
@JvmInline |
|
||||||
value class GalleryId(val l: Long) |
|
||||||
data class GalleryEntryWithMetadata( |
|
||||||
val id: GalleryId, |
|
||||||
val picture: Picture, |
|
||||||
val thumbnail: ImageBitmap, |
|
||||||
) |
|
||||||
|
|
||||||
class PhotoGallery(val deps: Dependencies) { |
|
||||||
private val _galleryStateFlow = MutableStateFlow<List<GalleryEntryWithMetadata>>(listOf()) |
|
||||||
val galleryStateFlow: StateFlow<List<GalleryEntryWithMetadata>> = _galleryStateFlow |
|
||||||
|
|
||||||
init { |
|
||||||
updatePictures() |
|
||||||
} |
|
||||||
|
|
||||||
fun updatePictures() { |
|
||||||
deps.ioScope.launch { |
|
||||||
try { |
|
||||||
val pics = getNewPictures(deps) |
|
||||||
_galleryStateFlow.emit(pics) |
|
||||||
} catch (e: CancellationException) { |
|
||||||
println("Rethrowing CancellationException with original cause") |
|
||||||
// https://kotlinlang.org/docs/exception-handling.html#exceptions-aggregation |
|
||||||
throw e |
|
||||||
} catch (e: Exception) { |
|
||||||
e.printStackTrace() |
|
||||||
deps.notification.notifyNoInternet() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private suspend fun getNewPictures(dependencies: Dependencies): List<GalleryEntryWithMetadata> { |
|
||||||
val pictures = dependencies.json.decodeFromString( |
|
||||||
ListSerializer(Picture.serializer()), |
|
||||||
dependencies.httpClient.get(PICTURES_DATA_URL).bodyAsText() |
|
||||||
) |
|
||||||
val miniatures = pictures |
|
||||||
.map { picture -> |
|
||||||
dependencies.ioScope.async { |
|
||||||
picture to dependencies.imageRepository.loadContent(picture.smallUrl) |
|
||||||
} |
|
||||||
} |
|
||||||
.awaitAll() |
|
||||||
.mapIndexed { index, pictureAndBitmap -> |
|
||||||
val (pic, bit) = pictureAndBitmap |
|
||||||
GalleryEntryWithMetadata(GalleryId(index.toLong()), pic, bit) |
|
||||||
} |
|
||||||
return miniatures |
|
||||||
} |
|
||||||
} |
|
@ -1,30 +0,0 @@ |
|||||||
package example.imageviewer.model |
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable |
|
||||||
|
|
||||||
@Serializable |
|
||||||
data class Picture(val big: String, val small: String) |
|
||||||
|
|
||||||
fun getNameURL(url: String): String = url.substring(url.lastIndexOf('/') + 1, url.length) |
|
||||||
val Picture.name: String get() { |
|
||||||
val realName = getNameURL(big) |
|
||||||
return mockNames.getOrElse(realName) { realName } |
|
||||||
} |
|
||||||
val Picture.bigUrl get() = "$BASE_URL/$big" |
|
||||||
val Picture.smallUrl get() = "$BASE_URL/$small" |
|
||||||
|
|
||||||
val mockNames = mapOf( |
|
||||||
"1.jpg" to "Gondolas", |
|
||||||
"2.jpg" to "Winter Pier", |
|
||||||
"3.jpg" to "Kitties outside", |
|
||||||
"4.jpg" to "Heap of trees", |
|
||||||
"5.jpg" to "Resilient Cacti", |
|
||||||
"6.jpg" to "Swirls", |
|
||||||
"7.jpg" to "Gradient Descent", |
|
||||||
"8.jpg" to "Sleepy in Seattle", |
|
||||||
"9.jpg" to "Lightful infrastructure", |
|
||||||
"10.jpg" to "Compose Pathway", |
|
||||||
"11.jpg" to "Rotary", |
|
||||||
"12.jpg" to "Towering", |
|
||||||
"13.jpg" to "Vasa" |
|
||||||
) |
|
@ -0,0 +1,62 @@ |
|||||||
|
package example.imageviewer.model |
||||||
|
|
||||||
|
import example.imageviewer.createUUID |
||||||
|
import kotlinx.datetime.Clock |
||||||
|
import kotlinx.datetime.Instant |
||||||
|
import kotlinx.datetime.TimeZone |
||||||
|
import kotlinx.datetime.toLocalDateTime |
||||||
|
import kotlinx.serialization.Serializable |
||||||
|
|
||||||
|
@Serializable |
||||||
|
class GpsPosition( |
||||||
|
val latitude: Double, |
||||||
|
val longitude: Double |
||||||
|
) |
||||||
|
|
||||||
|
sealed interface PictureData { |
||||||
|
val name: String |
||||||
|
val description: String |
||||||
|
val gps: GpsPosition |
||||||
|
val dateString: String |
||||||
|
|
||||||
|
class Resource( |
||||||
|
val resource: String, |
||||||
|
val thumbnailResource: String, |
||||||
|
override val name: String, |
||||||
|
override val description: String, |
||||||
|
override val gps: GpsPosition, |
||||||
|
override val dateString: String, |
||||||
|
) : PictureData |
||||||
|
|
||||||
|
@Serializable |
||||||
|
class Camera( |
||||||
|
val id: String, |
||||||
|
val timeStampSeconds: Long, |
||||||
|
override val name: String, |
||||||
|
override val description: String, |
||||||
|
override val gps: GpsPosition, |
||||||
|
) : PictureData { |
||||||
|
override fun equals(other: Any?): Boolean = (other as? Camera)?.id == id |
||||||
|
override fun hashCode(): Int = id.hashCode() |
||||||
|
override val dateString: String |
||||||
|
get(): String { |
||||||
|
val instantTime = Instant.fromEpochSeconds(timeStampSeconds, 0) |
||||||
|
val utcTime = instantTime.toLocalDateTime(TimeZone.UTC) |
||||||
|
val date = utcTime.date |
||||||
|
val monthStr = date.month.name.lowercase() |
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } |
||||||
|
.take(3) |
||||||
|
val dayStr = date.dayOfMonth |
||||||
|
return "$dayStr $monthStr." |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun createCameraPictureData(name: String, description: String, gps: GpsPosition) = |
||||||
|
PictureData.Camera( |
||||||
|
id = createUUID(), |
||||||
|
timeStampSeconds = Clock.System.now().epochSeconds, |
||||||
|
name = name, |
||||||
|
description = description, |
||||||
|
gps = gps, |
||||||
|
) |
@ -1,5 +1,12 @@ |
|||||||
package example.imageviewer |
package example.imageviewer |
||||||
|
|
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
|
import kotlinx.coroutines.CoroutineDispatcher |
||||||
|
|
||||||
expect fun Modifier.notchPadding(): Modifier |
expect fun Modifier.notchPadding(): Modifier |
||||||
|
|
||||||
|
expect class PlatformStorableImage |
||||||
|
|
||||||
|
expect fun createUUID(): String |
||||||
|
|
||||||
|
expect val ioDispatcher: CoroutineDispatcher |
||||||
|
@ -1,5 +0,0 @@ |
|||||||
package example.imageviewer.utils |
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher |
|
||||||
|
|
||||||
expect val ioDispatcher: CoroutineDispatcher |
|
@ -1,218 +0,0 @@ |
|||||||
package example.imageviewer.view |
|
||||||
|
|
||||||
import androidx.compose.foundation.* |
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource |
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState |
|
||||||
import androidx.compose.foundation.layout.* |
|
||||||
import androidx.compose.foundation.shape.CircleShape |
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape |
|
||||||
import androidx.compose.material3.* |
|
||||||
import androidx.compose.runtime.* |
|
||||||
import androidx.compose.ui.Alignment |
|
||||||
import androidx.compose.ui.Modifier |
|
||||||
import androidx.compose.ui.draw.clip |
|
||||||
import androidx.compose.ui.graphics.ImageBitmap |
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter |
|
||||||
import androidx.compose.ui.graphics.painter.Painter |
|
||||||
import androidx.compose.ui.input.key.* |
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned |
|
||||||
import androidx.compose.ui.unit.IntRect |
|
||||||
import androidx.compose.ui.unit.IntSize |
|
||||||
import androidx.compose.ui.unit.dp |
|
||||||
import example.imageviewer.Localization |
|
||||||
import example.imageviewer.core.BitmapFilter |
|
||||||
import example.imageviewer.core.FilterType |
|
||||||
import example.imageviewer.model.* |
|
||||||
import example.imageviewer.style.* |
|
||||||
import org.jetbrains.compose.resources.ExperimentalResourceApi |
|
||||||
import org.jetbrains.compose.resources.painterResource |
|
||||||
|
|
||||||
@Composable |
|
||||||
internal fun FullscreenImage( |
|
||||||
galleryId: GalleryId?, |
|
||||||
gallery: PhotoGallery, |
|
||||||
getImage: suspend (Picture) -> ImageBitmap, |
|
||||||
getFilter: (FilterType) -> BitmapFilter, |
|
||||||
localization: Localization, |
|
||||||
back: () -> Unit, |
|
||||||
) { |
|
||||||
val picture = gallery.galleryStateFlow.value.first { it.id == galleryId }.picture |
|
||||||
val availableFilters = FilterType.values().toList() |
|
||||||
var selectedFilters by remember { mutableStateOf(emptySet<FilterType>()) } |
|
||||||
|
|
||||||
val originalImageState = remember(galleryId) { mutableStateOf<ImageBitmap?>(null) } |
|
||||||
LaunchedEffect(galleryId) { |
|
||||||
if (galleryId != null) { |
|
||||||
originalImageState.value = getImage(picture) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
val originalImage = originalImageState.value |
|
||||||
val imageWithFilter = remember(originalImage, selectedFilters) { |
|
||||||
if (originalImage != null) { |
|
||||||
var result: ImageBitmap = originalImage |
|
||||||
for (filter in selectedFilters.map { getFilter(it) }) { |
|
||||||
result = filter.apply(result) |
|
||||||
} |
|
||||||
result |
|
||||||
} else { |
|
||||||
null |
|
||||||
} |
|
||||||
} |
|
||||||
Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) { |
|
||||||
Column { |
|
||||||
FullscreenImageBar( |
|
||||||
localization, |
|
||||||
picture.name, |
|
||||||
back, |
|
||||||
availableFilters, |
|
||||||
selectedFilters, |
|
||||||
onSelectFilter = { |
|
||||||
if (it !in selectedFilters) { |
|
||||||
selectedFilters += it |
|
||||||
} else { |
|
||||||
selectedFilters -= it |
|
||||||
} |
|
||||||
}) |
|
||||||
if (imageWithFilter != null) { |
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { |
|
||||||
val imageSize = IntSize(imageWithFilter.width, imageWithFilter.height) |
|
||||||
val scalableState = remember(imageSize) { ScalableState(imageSize) } |
|
||||||
val visiblePartOfImage: IntRect = scalableState.visiblePart |
|
||||||
Column { |
|
||||||
Slider( |
|
||||||
modifier = Modifier.fillMaxWidth(), |
|
||||||
value = scalableState.scale, |
|
||||||
valueRange = MIN_SCALE..MAX_SCALE, |
|
||||||
onValueChange = { scalableState.setScale(it) }, |
|
||||||
) |
|
||||||
Box( |
|
||||||
modifier = Modifier.fillMaxSize() |
|
||||||
.onGloballyPositioned { coordinates -> |
|
||||||
scalableState.changeBoxSize(coordinates.size) |
|
||||||
} |
|
||||||
.addUserInput(scalableState) |
|
||||||
) { |
|
||||||
Image( |
|
||||||
modifier = Modifier.fillMaxSize(), |
|
||||||
painter = BitmapPainter( |
|
||||||
imageWithFilter, |
|
||||||
srcOffset = visiblePartOfImage.topLeft, |
|
||||||
srcSize = visiblePartOfImage.size |
|
||||||
), |
|
||||||
contentDescription = null |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
Box( |
|
||||||
Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) |
|
||||||
.background(ImageviewerColors.fullScreenImageBackground).padding(16.dp) |
|
||||||
) { |
|
||||||
Row( |
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp), |
|
||||||
modifier = Modifier.padding(bottom = 16.dp) |
|
||||||
) { |
|
||||||
FilterButtons(availableFilters, selectedFilters, { |
|
||||||
if (it !in selectedFilters) { |
|
||||||
selectedFilters += it |
|
||||||
} else { |
|
||||||
selectedFilters -= it |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} else { |
|
||||||
LoadingScreen() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalResourceApi::class) |
|
||||||
@Composable |
|
||||||
private fun FullscreenImageBar( |
|
||||||
localization: Localization, |
|
||||||
pictureName: String?, |
|
||||||
onBack: () -> Unit, |
|
||||||
filters: List<FilterType>, |
|
||||||
selectedFilters: Set<FilterType>, |
|
||||||
onSelectFilter: (FilterType) -> Unit |
|
||||||
) { |
|
||||||
TopLayout( |
|
||||||
alignLeftContent = { |
|
||||||
Tooltip(localization.back) { |
|
||||||
CircularButton( |
|
||||||
painterResource("arrowleft.png"), |
|
||||||
onClick = { onBack() } |
|
||||||
) |
|
||||||
} |
|
||||||
}, |
|
||||||
alignRightContent = {}, |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
private fun FilterButtons( |
|
||||||
filters: List<FilterType>, |
|
||||||
selectedFilters: Set<FilterType>, |
|
||||||
onSelectFilter: (FilterType) -> Unit |
|
||||||
) { |
|
||||||
for (type in filters) { |
|
||||||
FilterButton(active = type in selectedFilters, |
|
||||||
type, |
|
||||||
onClick = { |
|
||||||
onSelectFilter(type) |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
@Composable |
|
||||||
private fun FilterButton( |
|
||||||
active: Boolean, |
|
||||||
type: FilterType, |
|
||||||
onClick: () -> Unit, |
|
||||||
) { |
|
||||||
val interactionSource = remember { MutableInteractionSource() } |
|
||||||
val filterButtonHover by interactionSource.collectIsHoveredAsState() |
|
||||||
Box( |
|
||||||
modifier = Modifier.background(color = ImageviewerColors.Transparent).clip(CircleShape) |
|
||||||
) { |
|
||||||
Tooltip(type.toString()) { |
|
||||||
Image( |
|
||||||
getFilterImage(active, type = type), |
|
||||||
contentDescription = null, |
|
||||||
Modifier.size(40.dp) |
|
||||||
.hoverable(interactionSource) |
|
||||||
.background(color = ImageviewerColors.buttonBackground(filterButtonHover)) |
|
||||||
.clickable { onClick() } |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@OptIn(ExperimentalResourceApi::class) |
|
||||||
@Composable |
|
||||||
private fun getFilterImage(active: Boolean, type: FilterType): Painter { |
|
||||||
return when (type) { |
|
||||||
FilterType.GrayScale -> if (active) { |
|
||||||
painterResource("grayscale_on.png") |
|
||||||
} else { |
|
||||||
painterResource("grayscale_off.png") |
|
||||||
} |
|
||||||
|
|
||||||
FilterType.Pixel -> if (active) { |
|
||||||
painterResource("pixel_on.png") |
|
||||||
} else { |
|
||||||
painterResource("pixel_off.png") |
|
||||||
} |
|
||||||
|
|
||||||
FilterType.Blur -> if (active) { |
|
||||||
painterResource("blur_on.png") |
|
||||||
} else { |
|
||||||
painterResource("blur_off.png") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,145 @@ |
|||||||
|
package example.imageviewer.view |
||||||
|
|
||||||
|
import androidx.compose.foundation.* |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.foundation.shape.CircleShape |
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||||
|
import androidx.compose.runtime.* |
||||||
|
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.ImageBitmap |
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter |
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned |
||||||
|
import androidx.compose.ui.unit.IntRect |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import example.imageviewer.LocalImageProvider |
||||||
|
import example.imageviewer.Localization |
||||||
|
import example.imageviewer.LocalLocalization |
||||||
|
import example.imageviewer.filter.FilterType |
||||||
|
import example.imageviewer.filter.getFilter |
||||||
|
import example.imageviewer.filter.getPlatformContext |
||||||
|
import example.imageviewer.model.* |
||||||
|
import example.imageviewer.style.* |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun FullscreenImageScreen( |
||||||
|
picture: PictureData, |
||||||
|
back: () -> Unit, |
||||||
|
) { |
||||||
|
val imageProvider = LocalImageProvider.current |
||||||
|
val localization: Localization = LocalLocalization.current |
||||||
|
val availableFilters = FilterType.values().toList() |
||||||
|
var selectedFilters by remember { mutableStateOf(emptySet<FilterType>()) } |
||||||
|
|
||||||
|
val originalImageState = remember(picture) { mutableStateOf<ImageBitmap?>(null) } |
||||||
|
LaunchedEffect(picture) { |
||||||
|
originalImageState.value = imageProvider.getImage(picture) |
||||||
|
} |
||||||
|
|
||||||
|
val platformContext = getPlatformContext() |
||||||
|
val originalImage = originalImageState.value |
||||||
|
val imageWithFilter = remember(originalImage, selectedFilters) { |
||||||
|
if (originalImage != null) { |
||||||
|
var result: ImageBitmap = originalImage |
||||||
|
for (filter in selectedFilters.map { getFilter(it) }) { |
||||||
|
result = filter.invoke(result, platformContext) |
||||||
|
} |
||||||
|
result |
||||||
|
} else { |
||||||
|
null |
||||||
|
} |
||||||
|
} |
||||||
|
Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) { |
||||||
|
if (imageWithFilter != null) { |
||||||
|
val scalableState = remember { ScalableState() } |
||||||
|
scalableState.updateImageSize(imageWithFilter.width, imageWithFilter.height) |
||||||
|
val visiblePartOfImage: IntRect = scalableState.visiblePart |
||||||
|
Box( |
||||||
|
Modifier.fillMaxSize() |
||||||
|
.onGloballyPositioned { coordinates -> |
||||||
|
scalableState.changeBoxSize(coordinates.size) |
||||||
|
} |
||||||
|
.addUserInput(scalableState) |
||||||
|
) { |
||||||
|
Image( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
painter = BitmapPainter( |
||||||
|
imageWithFilter, |
||||||
|
srcOffset = visiblePartOfImage.topLeft, |
||||||
|
srcSize = visiblePartOfImage.size |
||||||
|
), |
||||||
|
contentDescription = null, |
||||||
|
) |
||||||
|
Column( |
||||||
|
Modifier |
||||||
|
.align(Alignment.BottomCenter) |
||||||
|
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) |
||||||
|
.background(ImageviewerColors.filterButtonsBackground) |
||||||
|
.padding(16.dp), |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
FilterButtons( |
||||||
|
picture = picture, |
||||||
|
filters = availableFilters, |
||||||
|
selectedFilters = selectedFilters, |
||||||
|
onSelectFilter = { |
||||||
|
if (it !in selectedFilters) { |
||||||
|
selectedFilters += it |
||||||
|
} else { |
||||||
|
selectedFilters -= it |
||||||
|
} |
||||||
|
}, |
||||||
|
) |
||||||
|
ZoomControllerView(Modifier, scalableState) |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
LoadingScreen() |
||||||
|
} |
||||||
|
|
||||||
|
TopLayout( |
||||||
|
alignLeftContent = { |
||||||
|
Tooltip(localization.back) { |
||||||
|
BackButton(back) |
||||||
|
} |
||||||
|
}, |
||||||
|
alignRightContent = {}, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun FilterButtons( |
||||||
|
picture: PictureData, |
||||||
|
filters: List<FilterType>, |
||||||
|
selectedFilters: Set<FilterType>, |
||||||
|
onSelectFilter: (FilterType) -> Unit, |
||||||
|
) { |
||||||
|
val platformContext = getPlatformContext() |
||||||
|
Row( |
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp), |
||||||
|
modifier = Modifier.padding(bottom = 16.dp) |
||||||
|
) { |
||||||
|
for (type in filters) { |
||||||
|
Tooltip(type.toString()) { |
||||||
|
ThumbnailImage( |
||||||
|
modifier = Modifier |
||||||
|
.size(60.dp) |
||||||
|
.clip(CircleShape) |
||||||
|
.border( |
||||||
|
color = if (type in selectedFilters) Color.White else Color.Gray, |
||||||
|
width = 3.dp, |
||||||
|
shape = CircleShape |
||||||
|
) |
||||||
|
.clickable { |
||||||
|
onSelectFilter(type) |
||||||
|
}, |
||||||
|
picture = picture, |
||||||
|
filter = remember { { getFilter(type).invoke(it, platformContext) } } |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
package example.imageviewer.view |
||||||
|
|
||||||
|
import androidx.compose.foundation.Image |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.graphics.ImageBitmap |
||||||
|
import androidx.compose.ui.layout.ContentScale |
||||||
|
import example.imageviewer.LocalImageProvider |
||||||
|
import example.imageviewer.model.PictureData |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun ThumbnailImage( |
||||||
|
modifier: Modifier, |
||||||
|
picture: PictureData, |
||||||
|
filter: (ImageBitmap) -> ImageBitmap = remember { { it } }, |
||||||
|
) { |
||||||
|
val imageProvider = LocalImageProvider.current |
||||||
|
var imageBitmap by remember(picture) { mutableStateOf<ImageBitmap?>(null) } |
||||||
|
LaunchedEffect(picture) { |
||||||
|
imageBitmap = imageProvider.getThumbnail(picture) |
||||||
|
} |
||||||
|
imageBitmap?.let { |
||||||
|
Image( |
||||||
|
bitmap = filter(it), |
||||||
|
contentDescription = picture.name, |
||||||
|
modifier = modifier, |
||||||
|
contentScale = ContentScale.Crop, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
package example.imageviewer.view |
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxScope |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import example.imageviewer.model.ScalableState |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal expect fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 664 KiB |
After Width: | Height: | Size: 9.2 KiB |
After Width: | Height: | Size: 107 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 190 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 137 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 110 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 458 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 132 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 264 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 513 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 37 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 826 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 300 KiB |
After Width: | Height: | Size: 9.9 KiB |
After Width: | Height: | Size: 305 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 342 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 417 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 474 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 303 KiB |
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 6.8 KiB |