diff --git a/experimental/examples/imageviewer/README.md b/experimental/examples/imageviewer/README.md index cf4d6d3030..f9b65df0fb 100755 --- a/experimental/examples/imageviewer/README.md +++ b/experimental/examples/imageviewer/README.md @@ -7,7 +7,7 @@ based on Compose Multiplatform UI library (desktop, android and iOS). Choose a run configuration for an appropriate target in IDE and run it. -![run-configurations.png](run-configurations.png) +![run-configurations.png](screenshots/run-configurations.png) To run on iOS device, please correct `TEAM_ID` value in `iosApp/Configuration/Config.xcconfig` with your Apple Team ID. Alternatively, you may setup signing within XCode opening `iosApp/iosApp.xcworkspace` and then @@ -28,6 +28,8 @@ Then choose **iosApp** configuration in IDE and run it. ### Running Android application -Open project in IntelliJ IDEA or Android Studio and run "android" configuration. - -![Desktop](screenshots/imageviewer.png) + - Get a [Google Maps API key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) + - Add to local.properties: `sdk.dir=YOUR_SDK_PATH` + - Create a file in the root directory named `local.properties` with a single line that looks like + this, replacing YOUR_KEY with the key from step 1: `MAPS_API_KEY=YOUR_KEY` + - Open project in IntelliJ IDEA or Android Studio and run "android" configuration. diff --git a/experimental/examples/imageviewer/androidApp/build.gradle.kts b/experimental/examples/imageviewer/androidApp/build.gradle.kts index 31b3047fe1..0ada471c5c 100755 --- a/experimental/examples/imageviewer/androidApp/build.gradle.kts +++ b/experimental/examples/imageviewer/androidApp/build.gradle.kts @@ -2,6 +2,7 @@ plugins { kotlin("multiplatform") id("com.android.application") id("org.jetbrains.compose") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" } kotlin { @@ -29,3 +30,8 @@ android { targetCompatibility = JavaVersion.VERSION_11 } } + +secrets { + defaultPropertiesFileName = "default.local.properties" + propertiesFileName = "local.properties" +} diff --git a/experimental/examples/imageviewer/androidApp/src/androidMain/AndroidManifest.xml b/experimental/examples/imageviewer/androidApp/src/androidMain/AndroidManifest.xml index 025f2c4a29..344b9817ce 100755 --- a/experimental/examples/imageviewer/androidApp/src/androidMain/AndroidManifest.xml +++ b/experimental/examples/imageviewer/androidApp/src/androidMain/AndroidManifest.xml @@ -12,11 +12,12 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_imageviewer_round" android:supportsRtl="true" - android:theme="@style/Theme.AppCompat.Light.NoActionBar"> - + android:theme="@style/Theme.ImageViewer"> + + + + + diff --git a/experimental/examples/imageviewer/androidApp/src/androidMain/res/values/themes.xml b/experimental/examples/imageviewer/androidApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000000..78973ca7a6 --- /dev/null +++ b/experimental/examples/imageviewer/androidApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/build.gradle.kts b/experimental/examples/imageviewer/build.gradle.kts index 8d0c1f3d7c..0c00883af5 100644 --- a/experimental/examples/imageviewer/build.gradle.kts +++ b/experimental/examples/imageviewer/build.gradle.kts @@ -14,6 +14,5 @@ allprojects { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - mavenLocal() } } diff --git a/experimental/examples/imageviewer/default.local.properties b/experimental/examples/imageviewer/default.local.properties new file mode 100644 index 0000000000..a6307a6c86 --- /dev/null +++ b/experimental/examples/imageviewer/default.local.properties @@ -0,0 +1,2 @@ +# set MAPS_API_KEY at local.properties +MAPS_API_KEY=STUB_FOR_GOOGLE_MAPS_API_KEY diff --git a/experimental/examples/imageviewer/gradle.properties b/experimental/examples/imageviewer/gradle.properties index 527b0762d5..3ab32dfd3e 100644 --- a/experimental/examples/imageviewer/gradle.properties +++ b/experimental/examples/imageviewer/gradle.properties @@ -11,7 +11,6 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.native.useEmbeddableCompilerJar=true # Enable kotlin/native experimental memory model kotlin.native.binary.memoryModel=experimental -kotlin.version=1.8.0 +kotlin.version=1.8.10 agp.version=7.1.3 -compose.version=1.4.0-alpha01-dev975 -ktor.version=2.2.1 +compose.version=1.4.0-alpha01-dev985 diff --git a/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png b/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png deleted file mode 100644 index 3688407c6f..0000000000 Binary files a/experimental/examples/imageviewer/screenshots/desktop-run-configuration.png and /dev/null differ diff --git a/experimental/examples/imageviewer/screenshots/imageviewer.png b/experimental/examples/imageviewer/screenshots/imageviewer.png deleted file mode 100755 index 55ce953839..0000000000 Binary files a/experimental/examples/imageviewer/screenshots/imageviewer.png and /dev/null differ diff --git a/experimental/examples/imageviewer/run-configurations.png b/experimental/examples/imageviewer/screenshots/run-configurations.png similarity index 100% rename from experimental/examples/imageviewer/run-configurations.png rename to experimental/examples/imageviewer/screenshots/run-configurations.png diff --git a/experimental/examples/imageviewer/shared/build.gradle.kts b/experimental/examples/imageviewer/shared/build.gradle.kts index be5eaddcfd..c91980a6f3 100755 --- a/experimental/examples/imageviewer/shared/build.gradle.kts +++ b/experimental/examples/imageviewer/shared/build.gradle.kts @@ -9,7 +9,6 @@ plugins { } version = "1.0-SNAPSHOT" -val ktorVersion = extra["ktor.version"] kotlin { android() @@ -26,20 +25,21 @@ kotlin { baseName = "shared" isStatic = true } - extraSpecAttributes["resources"] = "['src/commonMain/resources/**', 'src/iosMain/resources/**']" + extraSpecAttributes["resources"] = + "['src/commonMain/resources/**', 'src/iosMain/resources/**']" } sourceSets { val commonMain by getting { dependencies { - implementation("io.ktor:ktor-client-core:$ktorVersion") implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") implementation(compose.material3) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") } } val androidMain by getting { @@ -47,12 +47,19 @@ kotlin { api("androidx.activity:activity-compose:1.6.1") api("androidx.appcompat:appcompat:1.6.1") api("androidx.core:core-ktx:1.9.0") - implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("androidx.camera:camera-camera2:1.2.1") + implementation("androidx.camera:camera-lifecycle:1.2.1") + implementation("androidx.camera:camera-view:1.2.1") + implementation("com.google.accompanist:accompanist-permissions:0.29.2-rc") + implementation("com.google.android.gms:play-services-maps:18.1.0") + implementation("com.google.android.gms:play-services-location:21.0.1") + implementation("com.google.maps.android:maps-compose:2.11.2") } } val iosMain by getting { dependencies { - implementation("io.ktor:ktor-client-darwin:$ktorVersion") + // TODO: update coroutines (or remove, if 1.8.0 will be presented in Compose) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0-Beta") } } val iosSimulatorArm64Main by getting { @@ -63,8 +70,6 @@ kotlin { val desktopMain by getting { dependencies { implementation(compose.desktop.common) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.6.4") - implementation("io.ktor:ktor-client-cio:$ktorVersion") } } } diff --git a/experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml b/experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml index 513200737a..a077657347 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml +++ b/experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml @@ -1,2 +1,7 @@ - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/AndroidImageStorage.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/AndroidImageStorage.kt new file mode 100644 index 0000000000..fbd96049b5 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/AndroidImageStorage.kt @@ -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, + private val ioScope: CoroutineScope +) : ImageStorage { + val largeImages = mutableMapOf() + val thumbnails = mutableMapOf() + 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]!! + } + +} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/Localization.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/Localization.android.kt new file mode 100644 index 0000000000..f196de35cf --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/Localization.android.kt @@ -0,0 +1,3 @@ +package example.imageviewer + +actual fun getCurrentLanguage(): AvailableLanguages = AvailableLanguages.EN diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/filter/BitmapFilter.android.kt similarity index 59% rename from experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.android.kt rename to experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/filter/BitmapFilter.android.kt index 1df3c3da04..369fc58913 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/GraphicsMath.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/filter/BitmapFilter.android.kt @@ -1,37 +1,58 @@ -package example.imageviewer.utils +package example.imageviewer.filter import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter -import android.graphics.Paint +import android.graphics.* import android.renderscript.Allocation import android.renderscript.Element import android.renderscript.RenderScript import android.renderscript.ScriptIntrinsicBlur +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext + +actual fun grayScaleFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyGrayScaleFilter(bitmap.asAndroidBitmap()).asImageBitmap() +} -fun scaleBitmapAspectRatio( - bitmap: Bitmap, - width: Int, - height: Int, - filter: Boolean = false -): Bitmap { - val boundW: Float = width.toFloat() - val boundH: Float = height.toFloat() +actual fun pixelFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyPixelFilter(bitmap.asAndroidBitmap()).asImageBitmap() +} - val ratioX: Float = boundW / bitmap.width - val ratioY: Float = boundH / bitmap.height - val ratio: Float = if (ratioX < ratioY) ratioX else ratioY +actual fun blurFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyBlurFilter(bitmap.asAndroidBitmap(), context.androidContext).asImageBitmap() +} - val resultH = (bitmap.height * ratio).toInt() - val resultW = (bitmap.width * ratio).toInt() +actual class PlatformContext(val androidContext: Context) - return Bitmap.createScaledBitmap(bitmap, resultW, resultH, filter) +@Composable +internal actual fun getPlatformContext(): PlatformContext = PlatformContext(LocalContext.current) + + +private 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(15f) + theIntrinsic.setInput(tmpIn) + theIntrinsic.forEach(tmpOut) + + tmpOut.copyTo(result) + + return result } -fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { +private fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { val result: Bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) @@ -48,34 +69,33 @@ fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { return result } -fun applyPixelFilter(bitmap: Bitmap): Bitmap { +private 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 / 4, h / 4) + result = scaleBitmapAspectRatio(result, w / 12, h / 12) 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)) +private fun scaleBitmapAspectRatio( + bitmap: Bitmap, + width: Int, + height: Int, + filter: Boolean = false +): Bitmap { + val boundW: Float = width.toFloat() + val boundH: Float = height.toFloat() - theIntrinsic.setRadius(3f) - theIntrinsic.setInput(tmpIn) - theIntrinsic.forEach(tmpOut) + val ratioX: Float = boundW / bitmap.width + val ratioY: Float = boundH / bitmap.height + val ratio: Float = if (ratioX < ratioY) ratioX else ratioY - tmpOut.copyTo(result) + val resultH = (bitmap.height * ratio).toInt() + val resultW = (bitmap.width * ratio).toInt() - return result + return Bitmap.createScaledBitmap(bitmap, resultW, resultH, filter) } + diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt deleted file mode 100755 index 5f9c242d9a..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt +++ /dev/null @@ -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() -} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt deleted file mode 100755 index e4ad86770b..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt +++ /dev/null @@ -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() -} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt deleted file mode 100755 index ae0a7197f5..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt +++ /dev/null @@ -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() -} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt index ff56091cc2..1474fff74e 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt @@ -3,5 +3,18 @@ package example.imageviewer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import kotlinx.coroutines.Dispatchers +import java.util.* actual fun Modifier.notchPadding(): Modifier = displayCutoutPadding().statusBarsPadding() + +class AndroidStorableImage( + val imageBitmap: ImageBitmap +) + +actual typealias PlatformStorableImage = AndroidStorableImage + +actual fun createUUID(): String = UUID.randomUUID().toString() + +actual val ioDispatcher = Dispatchers.IO diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt deleted file mode 100644 index 100d513e91..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/utils/IOScope.android.kt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt index 0909f1d68b..d1f18fb3df 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt @@ -1,21 +1,155 @@ 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.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.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 -internal actual fun CameraView(modifier: Modifier) { - Box(Modifier.fillMaxSize().background(Color.Black)) { - Text( - text = "Camera is not available on Android for now.", - color = Color.White, - modifier = Modifier.align(Alignment.Center) +private fun CameraWithGrantedPermission( + modifier: Modifier, + onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit +) { + val context = LocalContext.current + 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 { 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 = + 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 +} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt index 9bfadaaf5d..dfe9e4794d 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt @@ -5,29 +5,10 @@ import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext -import example.imageviewer.Dependencies -import example.imageviewer.ImageViewerCommon -import example.imageviewer.Localization -import example.imageviewer.Notification -import example.imageviewer.PopupNotification -import example.imageviewer.core.BitmapFilter -import example.imageviewer.core.FilterType -import example.imageviewer.model.ContentRepository -import example.imageviewer.model.adapter -import example.imageviewer.model.createNetworkRepository -import example.imageviewer.model.filtration.BlurFilter -import example.imageviewer.model.filtration.GrayScaleFilter -import example.imageviewer.model.filtration.PixelFilter -import example.imageviewer.shared.R +import example.imageviewer.* import example.imageviewer.style.ImageViewerTheme -import example.imageviewer.toImageBitmap -import example.imageviewer.utils.ioDispatcher -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers @Composable fun ImageViewerAndroid() { @@ -39,39 +20,11 @@ fun ImageViewerAndroid() { } } -private fun getDependencies(context: Context, ioScope: CoroutineScope) = object : Dependencies { - override val httpClient: HttpClient = HttpClient(OkHttp) - override val ioScope: CoroutineScope = ioScope - override fun getFilter(type: FilterType): BitmapFilter = - when (type) { - FilterType.GrayScale -> GrayScaleFilter() - FilterType.Pixel -> PixelFilter() - FilterType.Blur -> BlurFilter(context) - } - - override val localization: Localization = object : Localization { - override val back get() = context.getString(R.string.back) - override val appName get() = context.getString(R.string.app_name) - override val loading get() = context.getString(R.string.loading) - override val repoInvalid get() = context.getString(R.string.repo_invalid) - override val repoEmpty get() = context.getString(R.string.repo_empty) - override val noInternet get() = context.getString(R.string.no_internet) - override val loadImageUnavailable get() = context.getString(R.string.load_image_unavailable) - override val lastImage get() = context.getString(R.string.last_image) - override val firstImage get() = context.getString(R.string.first_image) - override val picture get() = context.getString(R.string.picture) - override val size get() = context.getString(R.string.size) - override val pixels get() = context.getString(R.string.pixels) - override val refreshUnavailable get() = context.getString(R.string.refresh_unavailable) - } - - override val imageRepository: ContentRepository = - createNetworkRepository(httpClient) - .adapter { it.toImageBitmap() } - +private fun getDependencies(context: Context, ioScope: CoroutineScope) = object : Dependencies() { override val notification: Notification = object : PopupNotification(localization) { override fun showPopUpMessage(text: String) { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() } } + override val imageStorage: ImageStorage = AndroidImageStorage(pictures, ioScope) } diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/LocationVisualizer.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/LocationVisualizer.android.kt index fd73ffc822..f1712e089b 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/LocationVisualizer.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/LocationVisualizer.android.kt @@ -1,19 +1,21 @@ package example.imageviewer.view -import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.painterResource +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.rememberCameraPositionState +import example.imageviewer.model.GpsPosition -@OptIn(ExperimentalResourceApi::class) @Composable -internal actual fun LocationVisualizer(modifier: Modifier) { - Image( - painter = painterResource("dummy_map.png"), - contentDescription = "Map", - contentScale = ContentScale.Crop, - modifier = modifier +internal actual fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) { + val currentLocation = LatLng(gps.latitude, gps.longitude) + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(currentLocation, 10f) + } + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState ) } diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/Tooltip.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/Tooltip.android.kt index 8e638fd7d1..d9d120e040 100644 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/Tooltip.android.kt +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/Tooltip.android.kt @@ -1,9 +1,13 @@ package example.imageviewer.view import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier @Composable -internal actual fun Tooltip(text: String, content: @Composable () -> Unit) { +internal actual fun Tooltip( + text: String, + content: @Composable () -> Unit +) { // No Tooltip for Android content() } diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ZoomControllerView.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ZoomControllerView.android.kt new file mode 100644 index 0000000000..2995b11cfb --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ZoomControllerView.android.kt @@ -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 +} diff --git a/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml b/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml index 134f6a332a..96638950f2 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml +++ b/experimental/examples/imageviewer/shared/src/androidMain/res/values-de/strings.xml @@ -1,17 +1,4 @@ ImageViewer - Bilder werden geladen... - Bildverzeichnis ist leer. - Kein Internetzugriff. - Bildverzeichnis beschädigt oder leer. - Kann Bilder nicht aktualisieren. - Kann volles Bild nicht laden. - Dies ist das letzte Bild. - Dies ist das erste Bild. - Bild: - Abmessungen: - Pixel. - Zurück - Aktualisieren diff --git a/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml b/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml index 2f055b36cb..bbae98ae64 100755 --- a/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml +++ b/experimental/examples/imageviewer/shared/src/androidMain/res/values/strings.xml @@ -1,16 +1,3 @@ My Memories - Loading images... - Repository is empty. - No internet access. - List of images in current repository is invalid or empty. - Cannot refresh images. - Cannot load full size image. - This is last image. - This is first image. - Picture: - Size: - pixels. - back - Refresh \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt index 3f4aad2ab4..5c5d356694 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt @@ -1,80 +1,99 @@ 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 example.imageviewer.core.BitmapFilter -import example.imageviewer.core.FilterType -import example.imageviewer.model.ContentRepository -import example.imageviewer.model.Picture -import example.imageviewer.model.name -import io.ktor.client.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.serialization.json.Json - -interface Dependencies { - val httpClient: HttpClient - val ioScope: CoroutineScope - fun getFilter(type: FilterType): BitmapFilter - val localization: Localization - val imageRepository: ContentRepository - val notification: Notification - val json: Json get() = jsonReader +import example.imageviewer.model.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.resource + +@OptIn(ExperimentalResourceApi::class) +abstract class Dependencies { + abstract val notification: Notification + abstract val imageStorage: ImageStorage + val pictures: SnapshotStateList = mutableStateListOf(*resourcePictures) + open val externalEvents: Flow = emptyFlow() + val localization: Localization = getCurrentLocalization() + val imageProvider: ImageProvider = object : ImageProvider { + override suspend fun getImage(picture: PictureData): ImageBitmap = when (picture) { + is PictureData.Resource -> { + 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 { - fun notifyInvalidRepo() - fun notifyRepoIsEmpty() - fun notifyNoInternet() - fun notifyLoadImageUnavailable() - fun notifyLastImage() - fun notifyFirstImage() - fun notifyImageData(picture: Picture) - fun notifyRefreshUnavailable() + fun notifyImageData(picture: PictureData) } abstract class PopupNotification(private val localization: Localization) : Notification { abstract fun showPopUpMessage(text: String) - - 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( + override fun notifyImageData(picture: PictureData) = showPopUpMessage( "${localization.picture} ${picture.name}" ) - - override fun notifyRefreshUnavailable() = showPopUpMessage( - """ - ${localization.noInternet} - ${localization.refreshUnavailable} - """.trimIndent() - ) } interface Localization { - val back: String val appName: String - val loading: String - val repoInvalid: String - val repoEmpty: String - val noInternet: String - val loadImageUnavailable: String - val lastImage: String - val firstImage: String + val back: String val picture: String - val size: String - val pixels: String - val refreshUnavailable: String + val takePhoto: String + val addPhoto: 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 { + noLocalProvidedFor("LocalLocalization") +} + +internal val LocalNotification = staticCompositionLocalOf { + noLocalProvidedFor("LocalNotification") +} + +internal val LocalImageProvider = staticCompositionLocalOf { + noLocalProvidedFor("LocalImageProvider") +} + +internal val LocalImageStorage = staticCompositionLocalOf { + noLocalProvidedFor("LocalImageStorage") +} + +internal val LocalInternalEvents = staticCompositionLocalOf> { + noLocalProvidedFor("LocalInternalEvents") } -private val jsonReader: Json = Json { - ignoreUnknownKeys = true +private fun noLocalProvidedFor(name: String): Nothing { + error("CompositionLocal $name not present") } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt index d6af7958c8..2897d01615 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt @@ -1,47 +1,52 @@ package example.imageviewer -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.with import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier -import example.imageviewer.model.CameraPage -import example.imageviewer.model.FullScreenPage -import example.imageviewer.model.GalleryPage -import example.imageviewer.model.MemoryPage -import example.imageviewer.model.Page -import example.imageviewer.model.PhotoGallery -import example.imageviewer.model.bigUrl -import example.imageviewer.view.CameraScreen -import example.imageviewer.view.FullscreenImage -import example.imageviewer.view.GalleryScreen -import example.imageviewer.view.MemoryScreen -import example.imageviewer.view.NavigationStack -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import example.imageviewer.model.* +import example.imageviewer.view.* enum class ExternalImageViewerEvent { Foward, - Back + Back, + Escape, } @OptIn(ExperimentalAnimationApi::class) @Composable internal fun ImageViewerCommon( - dependencies: Dependencies, - externalEvents: Flow = emptyFlow() + dependencies: Dependencies ) { - val photoGallery = remember { PhotoGallery(dependencies) } - val rootGalleryPage = GalleryPage(photoGallery, externalEvents) - val navigationStack = remember { NavigationStack(rootGalleryPage) } + CompositionLocalProvider( + LocalLocalization provides dependencies.localization, + LocalNotification provides dependencies.notification, + LocalImageProvider provides dependencies.imageProvider, + LocalImageStorage provides dependencies.imageStorage, + LocalInternalEvents provides dependencies.externalEvents + ) { + ImageViewerWithProvidedDependencies(dependencies.pictures) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +internal fun ImageViewerWithProvidedDependencies( + pictures: SnapshotStateList +) { + val selectedPictureIndex: MutableState = mutableStateOf(0) + val navigationStack = remember { NavigationStack(GalleryPage()) } + val externalEvents = LocalInternalEvents.current + LaunchedEffect(Unit) { + externalEvents.collect { + if (it == ExternalImageViewerEvent.Escape) { + navigationStack.back() + } + } + } Surface(modifier = Modifier.fillMaxSize()) { AnimatedContent(targetState = navigationStack.lastWithIndex(), transitionSpec = { @@ -60,24 +65,19 @@ internal fun ImageViewerCommon( when (page) { is GalleryPage -> { GalleryScreen( - page, - photoGallery, - dependencies, + pictures = pictures, + selectedPictureIndex = selectedPictureIndex, onClickPreviewPicture = { previewPictureId -> navigationStack.push(MemoryPage(previewPictureId)) - }, - onMakeNewMemory = { - navigationStack.push(CameraPage()) - }) + } + ) { + navigationStack.push(CameraPage()) + } } is FullScreenPage -> { - FullscreenImage( - galleryId = page.galleryId, - gallery = photoGallery, - getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, - getFilter = { dependencies.getFilter(it) }, - localization = dependencies.localization, + FullscreenImageScreen( + picture = page.picture, back = { navigationStack.back() } @@ -86,10 +86,8 @@ internal fun ImageViewerCommon( is MemoryPage -> { MemoryScreen( + pictures = pictures, memoryPage = page, - photoGallery = photoGallery, - getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, - localization = dependencies.localization, onSelectRelatedMemory = { galleryId -> navigationStack.push(MemoryPage(galleryId)) }, @@ -98,13 +96,18 @@ internal fun ImageViewerCommon( }, onHeaderClick = { galleryId -> navigationStack.push(FullScreenPage(galleryId)) - }) + }, + ) } is CameraPage -> { CameraScreen( - localization = dependencies.localization, - onBack = { navigationStack.back() } + onBack = { resetSelectedPicture -> + if (resetSelectedPicture) { + selectedPictureIndex.value = 0 + } + navigationStack.back() + }, ) } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Localization.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Localization.kt new file mode 100644 index 0000000000..535d77b4cf --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Localization.kt @@ -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 +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/NameAndDescription.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/NameAndDescription.common.kt new file mode 100644 index 0000000000..22bfb35e4e --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/NameAndDescription.common.kt @@ -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 + ) + } + } +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ResourcePictures.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ResourcePictures.kt new file mode 100644 index 0000000000..4544dc2c14 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ResourcePictures.kt @@ -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) + ), +) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/BitmapFilter.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/BitmapFilter.kt deleted file mode 100755 index ad8da1231a..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/BitmapFilter.kt +++ /dev/null @@ -1,7 +0,0 @@ -package example.imageviewer.core - -import androidx.compose.ui.graphics.ImageBitmap - -interface BitmapFilter { - fun apply(bitmap: ImageBitmap): ImageBitmap -} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/BitmapFilter.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/BitmapFilter.common.kt new file mode 100755 index 0000000000..c168294a2a --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/BitmapFilter.common.kt @@ -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 diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/FilterType.kt similarity index 58% rename from experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt rename to experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/FilterType.kt index 53ad4ee60f..ed1f9d65a2 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/core/FilterType.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/filter/FilterType.kt @@ -1,5 +1,5 @@ -package example.imageviewer.core +package example.imageviewer.filter enum class FilterType { GrayScale, Pixel, Blur -} \ No newline at end of file +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt index 28bfc3122f..1dfd688c75 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt @@ -1,7 +1,5 @@ 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 MIN_SCALE = 1f const val TOAST_DURATION = 3000L diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ContentRepository.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ContentRepository.kt deleted file mode 100644 index 73b19297ff..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ContentRepository.kt +++ /dev/null @@ -1,23 +0,0 @@ -package example.imageviewer.model - -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* - -interface ContentRepository { - suspend fun loadContent(url: String): T -} - -fun createNetworkRepository(ktorClient: HttpClient) = object : ContentRepository { - override suspend fun loadContent(url: String): ByteArray = - ktorClient.get(urlString = url).readBytes() -} - -fun ContentRepository.adapter(transform: (A) -> B): ContentRepository { - val origin = this - return object : ContentRepository { - override suspend fun loadContent(url: String): B { - return transform(origin.loadContent(url)) - } - } -} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt index c3c07ef0a2..100bbf6e04 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt @@ -1,59 +1,8 @@ package example.imageviewer.model -import androidx.compose.foundation.ScrollState -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 interface Page -sealed class Page - -class MemoryPage(val galleryId: GalleryId) : Page() { - val scrollState = ScrollState(0) -} - -class CameraPage : Page() - -class FullScreenPage(val galleryId: GalleryId) : Page() - -class GalleryPage( - val photoGallery: PhotoGallery, - val externalEvents: Flow -) : 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 } - } -} \ No newline at end of file +class MemoryPage(val picture: PictureData) : Page +class CameraPage : Page +class FullScreenPage(val picture: PictureData) : Page +class GalleryPage : Page diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PhotoGallery.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PhotoGallery.kt deleted file mode 100644 index 469fbd4588..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PhotoGallery.kt +++ /dev/null @@ -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>(listOf()) - val galleryStateFlow: StateFlow> = _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 { - 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 - } -} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt deleted file mode 100644 index 134a5acb70..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Picture.kt +++ /dev/null @@ -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" -) \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PictureData.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PictureData.kt new file mode 100644 index 0000000000..63c91a268d --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PictureData.kt @@ -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, + ) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt index 96584a20c8..59b003ffaf 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt @@ -8,7 +8,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize -class ScalableState(val imageSize: IntSize) { +class ScalableState() { + var imageSize by mutableStateOf(IntSize(0, 0)) var boxSize by mutableStateOf(IntSize(1, 1)) var offset by mutableStateOf(IntOffset.Zero) var scale by mutableStateOf(1f) @@ -58,6 +59,11 @@ fun ScalableState.addDragAmount(diff: Offset) { updateOffsetLimits() } +fun ScalableState.updateImageSize(width: Int, height: Int) { + imageSize = IntSize(width, height) + updateOffsetLimits() +} + private fun ScalableState.updateOffsetLimits() { if (offset.x + visiblePart.width > imageSize.width) { changeOffset(x = imageSize.width - visiblePart.width) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt index 1c6f24ba35..12e657fb48 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt @@ -1,5 +1,12 @@ package example.imageviewer import androidx.compose.ui.Modifier +import kotlinx.coroutines.CoroutineDispatcher expect fun Modifier.notchPadding(): Modifier + +expect class PlatformStorableImage + +expect fun createUUID(): String + +expect val ioDispatcher: CoroutineDispatcher diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt index b0056ac91c..f9600d542c 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt @@ -26,8 +26,8 @@ object ImageviewerColors { val onBackground = Color(0xFF19191C) val fullScreenImageBackground = Color(0xFF19191C) - - val uiLightBlack = Color(25, 25, 28, 180) + val filterButtonsBackground = fullScreenImageBackground.copy(alpha = 0.7f) + val uiLightBlack = Color(25, 25, 28).copy(alpha = 0.7f) val textOnImage = Color.White val noteBlockBackground = Color(0xFFF3F3F4) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt deleted file mode 100644 index 1e31a01e01..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/IOScope.kt +++ /dev/null @@ -1,5 +0,0 @@ -package example.imageviewer.utils - -import kotlinx.coroutines.CoroutineDispatcher - -expect val ioDispatcher: CoroutineDispatcher \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt index afbd19eee8..7495c810ce 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt @@ -1,24 +1,35 @@ package example.imageviewer.view -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import example.imageviewer.Localization -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.painterResource +import androidx.compose.ui.graphics.Color +import example.imageviewer.LocalImageStorage +import kotlinx.coroutines.delay -@OptIn(ExperimentalResourceApi::class) @Composable -internal fun CameraScreen(localization: Localization, onBack: () -> Unit) { - Box(Modifier.fillMaxSize()) { - CameraView(Modifier.fillMaxSize()) +internal fun CameraScreen(onBack: (resetSelectedPicture: Boolean) -> Unit) { + val storage = LocalImageStorage.current + var showCamera by remember { mutableStateOf(false) } + LaunchedEffect(onBack) { + if (!showCamera) { + delay(300) // for animation + showCamera = true + } + } + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (showCamera) { + CameraView(Modifier.fillMaxSize(), onCapture = { picture, image -> + storage.saveImage(picture, image) + onBack(true) + }) + } TopLayout( alignLeftContent = { - Tooltip(localization.back) { - CircularButton( - painterResource("arrowleft.png"), - onClick = { onBack() } - ) + BackButton { + onBack(false) } }, alignRightContent = {}, diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt index 1d5b5a65bb..f49bfc574b 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt @@ -2,6 +2,9 @@ package example.imageviewer.view import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import example.imageviewer.ImageStorage +import example.imageviewer.PlatformStorableImage +import example.imageviewer.model.PictureData @Composable -internal expect fun CameraView(modifier: Modifier) +internal expect fun CameraView(modifier: Modifier, onCapture: (picture: PictureData.Camera, image: PlatformStorableImage)->Unit) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt index 6d9b60d6ef..c20ed9841d 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt @@ -12,7 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp +import example.imageviewer.LocalLocalization import example.imageviewer.style.ImageviewerColors +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource @Composable internal fun CircularButton( @@ -21,7 +24,7 @@ internal fun CircularButton( onClick: () -> Unit, ) { Box( - modifier.size(50.dp).clip(CircleShape).background(ImageviewerColors.uiLightBlack) + modifier.size(54.dp).clip(CircleShape).background(ImageviewerColors.uiLightBlack) .clickable { onClick() }, contentAlignment = Alignment.Center ) { Image( @@ -31,3 +34,14 @@ internal fun CircularButton( ) } } + +@OptIn(ExperimentalResourceApi::class) +@Composable +internal fun BackButton(onClick: () -> Unit) { + Tooltip(LocalLocalization.current.back) { + CircularButton( + painterResource("arrowleft.png"), + onClick = onClick + ) + } +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt deleted file mode 100644 index 33cb71bd0c..0000000000 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImage.kt +++ /dev/null @@ -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()) } - - val originalImageState = remember(galleryId) { mutableStateOf(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, - selectedFilters: Set, - onSelectFilter: (FilterType) -> Unit -) { - TopLayout( - alignLeftContent = { - Tooltip(localization.back) { - CircularButton( - painterResource("arrowleft.png"), - onClick = { onBack() } - ) - } - }, - alignRightContent = {}, - ) -} - -@Composable -private fun FilterButtons( - filters: List, - selectedFilters: Set, - 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") - } - } -} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt new file mode 100644 index 0000000000..3e35a0b57c --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt @@ -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()) } + + val originalImageState = remember(picture) { mutableStateOf(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, + selectedFilters: Set, + 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) } } + ) + } + } + } +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt index 7229636fbc..7cc185fb27 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt @@ -2,9 +2,16 @@ package example.imageviewer.view +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.with import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -12,19 +19,16 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList 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.layout.ContentScale import androidx.compose.ui.unit.dp -import example.imageviewer.Dependencies -import example.imageviewer.ExternalImageViewerEvent -import example.imageviewer.model.GalleryEntryWithMetadata -import example.imageviewer.model.GalleryId -import example.imageviewer.model.GalleryPage -import example.imageviewer.model.PhotoGallery -import example.imageviewer.model.bigUrl +import example.imageviewer.* +import example.imageviewer.model.* import example.imageviewer.style.ImageviewerColors import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource @@ -38,71 +42,88 @@ enum class GalleryStyle { @OptIn(ExperimentalResourceApi::class) @Composable internal fun GalleryScreen( - galleryPage: GalleryPage, - photoGallery: PhotoGallery, - dependencies: Dependencies, - onClickPreviewPicture: (GalleryId) -> Unit, + pictures: SnapshotStateList, + selectedPictureIndex: MutableState, + onClickPreviewPicture: (PictureData) -> Unit, onMakeNewMemory: () -> Unit ) { - val pictures by photoGallery.galleryStateFlow.collectAsState() + fun nextImage() { + selectedPictureIndex.value = + (selectedPictureIndex.value + 1).mod(pictures.size) + } + + fun previousImage() { + selectedPictureIndex.value = + (selectedPictureIndex.value - 1).mod(pictures.size) + } + + fun selectPicture(picture: PictureData) { + selectedPictureIndex.value = pictures.indexOfFirst { it == picture } + } + + val picture = pictures.getOrNull(selectedPictureIndex.value) + + var galleryStyle by remember { mutableStateOf(GalleryStyle.SQUARES) } + val externalEvents = LocalInternalEvents.current LaunchedEffect(Unit) { - galleryPage.externalEvents.collect { + externalEvents.collect { when (it) { - ExternalImageViewerEvent.Foward -> galleryPage.nextImage() - ExternalImageViewerEvent.Back -> galleryPage.previousImage() + ExternalImageViewerEvent.Foward -> nextImage() + ExternalImageViewerEvent.Back -> previousImage() + else -> {} } } } Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { Box { - PreviewImage( - getImage = { dependencies.imageRepository.loadContent(it.bigUrl) }, - picture = galleryPage.galleryEntry, onClick = { - galleryPage.pictureId?.let(onClickPreviewPicture) - } - ) + picture?.let { + PreviewImage( + picture = it, onClick = { + onClickPreviewPicture(it) + } + ) + } TopLayout( alignLeftContent = {}, alignRightContent = { CircularButton(painterResource("list_view.png")) { - galleryPage.toggleGalleryStyle() + galleryStyle = when (galleryStyle) { + GalleryStyle.SQUARES -> GalleryStyle.LIST + GalleryStyle.LIST -> GalleryStyle.SQUARES + } } }, ) } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - when (galleryPage.galleryStyle) { + when (galleryStyle) { GalleryStyle.SQUARES -> SquaresGalleryView( - pictures, - galleryPage.pictureId, - onSelect = { galleryPage.selectPicture(it) }, + images = pictures, + selectedImage = picture, + onSelect = { selectPicture(it) }, ) GalleryStyle.LIST -> ListGalleryView( - pictures, - dependencies, - onSelect = { galleryPage.selectPicture(it) }, - onFullScreen = { onClickPreviewPicture(it) } + pictures = pictures, + onSelect = { selectPicture(it) }, + onFullScreen = { onClickPreviewPicture(it) }, ) } CircularButton( image = painterResource("plus.png"), - modifier = Modifier.align(Alignment.BottomCenter).padding(48.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(36.dp), onClick = onMakeNewMemory, ) } } - if (pictures.isEmpty()) { - LoadingScreen(dependencies.localization.loading) - } } @Composable private fun SquaresGalleryView( - images: List, - selectedImage: GalleryId?, - onSelect: (GalleryId) -> Unit, + images: List, + selectedImage: PictureData?, + onSelect: (PictureData) -> Unit, ) { Column { Spacer(Modifier.height(4.dp)) @@ -111,11 +132,10 @@ private fun SquaresGalleryView( verticalArrangement = Arrangement.spacedBy(1.dp), horizontalArrangement = Arrangement.spacedBy(1.dp) ) { - itemsIndexed(images) { idx, image -> - val isSelected = image.id == selectedImage - val (picture, bitmap) = image - SquareMiniature( - image.thumbnail, + itemsIndexed(images) { idx, picture -> + val isSelected = picture == selectedImage + SquareThumbnail( + picture = picture, onClick = { onSelect(picture) }, isHighlighted = isSelected ) @@ -126,21 +146,21 @@ private fun SquaresGalleryView( @OptIn(ExperimentalResourceApi::class) @Composable -internal fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick: () -> Unit) { +internal fun SquareThumbnail( + picture: PictureData, + isHighlighted: Boolean, + onClick: () -> Unit +) { Box( - Modifier.aspectRatio(1.0f).clickable { onClick() }, + Modifier.aspectRatio(1.0f).clickable(onClick = onClick), contentAlignment = Alignment.BottomEnd ) { - Image( - bitmap = image, - contentDescription = null, - modifier = Modifier.fillMaxSize().clickable { onClick() }.then( - if (isHighlighted) { - Modifier//.border(BorderStroke(5.dp, Color.White)) - } else Modifier - ), - contentScale = ContentScale.Crop - ) + Tooltip(picture.name) { + ThumbnailImage( + modifier = Modifier.fillMaxSize(), + picture = picture, + ) + } if (isHighlighted) { Box(Modifier.fillMaxSize().background(ImageviewerColors.uiLightBlack)) Box( @@ -169,28 +189,26 @@ internal fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick @Composable private fun ListGalleryView( - pictures: List, - dependencies: Dependencies, - onSelect: (GalleryId) -> Unit, - onFullScreen: (GalleryId) -> Unit + pictures: List, + onSelect: (PictureData) -> Unit, + onFullScreen: (PictureData) -> Unit, ) { + val notification = LocalNotification.current ScrollableColumn( modifier = Modifier.fillMaxSize() ) { Spacer(modifier = Modifier.height(10.dp)) - for ((idx, picWithThumb) in pictures.withIndex()) { - val (galleryId, picture, miniature) = picWithThumb - Miniature( - picture = picture, - image = miniature, + for (p in pictures.withIndex()) { + Thumbnail( + picture = p.value, onClickSelect = { - onSelect(galleryId) + onSelect(p.value) }, onClickFullScreen = { - onFullScreen(galleryId) + onFullScreen(p.value) }, onClickInfo = { - dependencies.notification.notifyImageData(picture) + notification.notifyImageData(p.value) }, ) Spacer(modifier = Modifier.height(10.dp)) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LocationVisualizer.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LocationVisualizer.common.kt index b780d6fe00..33412b7452 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LocationVisualizer.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/LocationVisualizer.common.kt @@ -2,6 +2,7 @@ package example.imageviewer.view import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import example.imageviewer.model.GpsPosition @Composable -internal expect fun LocationVisualizer(modifier: Modifier) \ No newline at end of file +internal expect fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt index d279345b82..2c6a47fde5 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,12 +25,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import example.imageviewer.Localization -import example.imageviewer.model.GalleryEntryWithMetadata -import example.imageviewer.model.GalleryId -import example.imageviewer.model.MemoryPage -import example.imageviewer.model.PhotoGallery -import example.imageviewer.model.Picture +import example.imageviewer.ImageProvider +import example.imageviewer.LocalImageProvider +import example.imageviewer.model.* import example.imageviewer.style.ImageviewerColors import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource @@ -37,22 +35,19 @@ import org.jetbrains.compose.resources.painterResource @OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class) @Composable internal fun MemoryScreen( + pictures: SnapshotStateList, memoryPage: MemoryPage, - photoGallery: PhotoGallery, - getImage: suspend (Picture) -> ImageBitmap, - localization: Localization, - onSelectRelatedMemory: (GalleryId) -> Unit, + onSelectRelatedMemory: (PictureData) -> Unit, onBack: () -> Unit, - onHeaderClick: (GalleryId) -> Unit + onHeaderClick: (PictureData) -> Unit, ) { - val pictures by photoGallery.galleryStateFlow.collectAsState() - val picture = pictures.first { it.id == memoryPage.galleryId } - var headerImage by remember(picture) { mutableStateOf(picture.thumbnail) } - LaunchedEffect(picture) { - headerImage = getImage(picture.picture) + val imageProvider = LocalImageProvider.current + var headerImage: ImageBitmap? by remember(memoryPage.picture) { mutableStateOf(null) } + LaunchedEffect(memoryPage.picture) { + headerImage = imageProvider.getImage(memoryPage.picture) } Box { - val scrollState = memoryPage.scrollState + val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxWidth() @@ -68,22 +63,20 @@ internal fun MemoryScreen( }, contentAlignment = Alignment.Center ) { - MemoryHeader(headerImage, onClick = { onHeaderClick(memoryPage.galleryId) }) + headerImage?.let { + MemoryHeader( + it, + picture = memoryPage.picture, + onClick = { onHeaderClick(memoryPage.picture) } + ) + } } Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { Column { Headliner("Note") - Collapsible( - """ - I took a picture with my iPhone 14 at 17:45. The picture ended up being 3024 x 4032 pixels. ✨ - - I took multiple additional photos of the same subject, but they turned out not quite as well, so I decided to keep this specific one as a memory. - - I might upload this picture to Unsplash at some point, since other people might also enjoy this picture. So it would make sense to not keep it to myself! 😄 - """.trimIndent() - ) + Collapsible(memoryPage.picture.description) Headliner("Related memories") - RelatedMemoriesVisualizer(pictures, onSelectRelatedMemory) + RelatedMemoriesVisualizer(pictures, imageProvider, onSelectRelatedMemory) Headliner("Place") val locationShape = RoundedCornerShape(10.dp) LocationVisualizer( @@ -91,7 +84,9 @@ internal fun MemoryScreen( .clip(locationShape) .border(1.dp, Color.Gray, locationShape) .fillMaxWidth() - .height(200.dp) + .height(200.dp), + gps = memoryPage.picture.gps, + title = memoryPage.picture.name, ) Spacer(Modifier.height(50.dp)) Row( @@ -121,12 +116,7 @@ internal fun MemoryScreen( } TopLayout( alignLeftContent = { - Tooltip(localization.back) { - CircularButton( - painterResource("arrowleft.png"), - onClick = { onBack() } - ) - } + BackButton(onBack) }, alignRightContent = {}, ) @@ -134,7 +124,7 @@ internal fun MemoryScreen( } @Composable -private fun MemoryHeader(bitmap: ImageBitmap, onClick: () -> Unit) { +private fun MemoryHeader(bitmap: ImageBitmap, picture: PictureData, onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } Box(modifier = Modifier.clickable(interactionSource, null, onClick = { onClick() })) { @@ -145,7 +135,7 @@ private fun MemoryHeader(bitmap: ImageBitmap, onClick: () -> Unit) { modifier = Modifier.fillMaxSize() ) MagicButtonOverlay(onClick) - MemoryTextOverlay() + MemoryTextOverlay(picture) } } @@ -160,7 +150,7 @@ internal fun BoxScope.MagicButtonOverlay(onClick: () -> Unit) { } @Composable -internal fun BoxScope.MemoryTextOverlay() { +internal fun BoxScope.MemoryTextOverlay(picture: PictureData) { val shadowTextStyle = LocalTextStyle.current.copy( shadow = Shadow( color = Color.Black.copy(0.75f), @@ -172,7 +162,7 @@ internal fun BoxScope.MemoryTextOverlay() { modifier = Modifier.align(Alignment.BottomStart).padding(start = 12.dp, bottom = 16.dp) ) { Text( - "28. Feb", + text = picture.dateString, textAlign = TextAlign.Left, color = Color.White, fontSize = 20.sp, @@ -183,7 +173,7 @@ internal fun BoxScope.MemoryTextOverlay() { ) Spacer(Modifier.height(1.dp)) Text( - "London", + text = picture.name, textAlign = TextAlign.Left, color = Color.White, fontSize = 14.sp, @@ -233,8 +223,9 @@ internal fun Headliner(s: String) { @Composable internal fun RelatedMemoriesVisualizer( - ps: List, - onSelectRelatedMemory: (GalleryId) -> Unit + ps: List, + imageProvider: ImageProvider, + onSelectRelatedMemory: (PictureData) -> Unit ) { Box( modifier = Modifier.padding(10.dp, 0.dp).clip(RoundedCornerShape(10.dp)).fillMaxWidth() @@ -244,7 +235,7 @@ internal fun RelatedMemoriesVisualizer( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(ps) { idx, item -> - RelatedMemory(idx, item, onSelectRelatedMemory) + RelatedMemory(idx, item, imageProvider, onSelectRelatedMemory) } } } @@ -253,13 +244,14 @@ internal fun RelatedMemoriesVisualizer( @Composable internal fun RelatedMemory( index: Int, - galleryEntry: GalleryEntryWithMetadata, - onSelectRelatedMemory: (GalleryId) -> Unit + galleryEntry: PictureData, + imageProvider: ImageProvider, + onSelectRelatedMemory: (PictureData) -> Unit ) { Box(Modifier.size(130.dp).clip(RoundedCornerShape(8.dp))) { - SquareMiniature( - galleryEntry.thumbnail, - false, - onClick = { onSelectRelatedMemory(galleryEntry.id) }) + SquareThumbnail( + picture = galleryEntry, + isHighlighted = false, + onClick = { onSelectRelatedMemory(galleryEntry) }) } -} \ No newline at end of file +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt index 8e167efc9b..aedeeb4305 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/PreviewImage.common.kt @@ -27,16 +27,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import example.imageviewer.model.GalleryEntryWithMetadata -import example.imageviewer.model.Picture +import example.imageviewer.LocalImageProvider +import example.imageviewer.model.PictureData @OptIn(ExperimentalAnimationApi::class) @Composable internal fun PreviewImage( - picture: GalleryEntryWithMetadata?, + picture: PictureData, onClick: () -> Unit, - getImage: suspend (Picture) -> ImageBitmap ) { + val imageProvider = LocalImageProvider.current val interactionSource = remember { MutableInteractionSource() } Box( Modifier.fillMaxWidth().height(393.dp).background(Color.Black), @@ -59,11 +59,9 @@ internal fun PreviewImage( ) } ) { currentPicture -> - var image by remember(currentPicture) { mutableStateOf(currentPicture?.thumbnail) } + var image: ImageBitmap? by remember(currentPicture) { mutableStateOf(null) } LaunchedEffect(currentPicture) { - if (currentPicture != null) { - image = getImage(currentPicture.picture) - } + image = imageProvider.getImage(currentPicture) } if (image != null) { Box(Modifier.fillMaxSize()) { @@ -74,7 +72,7 @@ internal fun PreviewImage( .fillMaxSize(), contentScale = ContentScale.Crop ) - MemoryTextOverlay() + MemoryTextOverlay(currentPicture) } } else { Spacer( diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Thumbnail.kt similarity index 66% rename from experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt rename to experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Thumbnail.kt index 83f36eaa49..ed2405eb05 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Miniature.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Thumbnail.kt @@ -4,40 +4,28 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable 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.layout.ContentScale import androidx.compose.ui.unit.dp -import example.imageviewer.model.Picture -import example.imageviewer.model.name +import example.imageviewer.model.PictureData import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource @OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class) @Composable -internal fun Miniature( - picture: Picture, - image: ImageBitmap?, +internal fun Thumbnail( + picture: PictureData, onClickSelect: () -> Unit, onClickFullScreen: () -> Unit, - onClickInfo: () -> Unit, + onClickInfo: () -> Unit ) { Card( modifier = Modifier.padding(start = 10.dp, end = 10.dp).height(70.dp) @@ -52,20 +40,14 @@ internal fun Miniature( ) { Row(modifier = Modifier.padding(end = 30.dp)) { - val modifier = Modifier.height(70.dp) - .width(70.dp) - if (image != null) { - Image( - image, - contentDescription = null, - modifier = modifier + Tooltip(picture.name) { + ThumbnailImage( + modifier = Modifier.size(70.dp) .clip(CircleShape) .border(BorderStroke(1.dp, Color.White), CircleShape) .clickable { onClickFullScreen() }, - contentScale = ContentScale.Crop + picture = picture, ) - } else { - CircularProgressIndicator(modifier) } Text( text = picture.name, diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ThumbnailImage.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ThumbnailImage.kt new file mode 100644 index 0000000000..a3fa9f8904 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ThumbnailImage.kt @@ -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(null) } + LaunchedEffect(picture) { + imageBitmap = imageProvider.getThumbnail(picture) + } + imageBitmap?.let { + Image( + bitmap = filter(it), + contentDescription = picture.name, + modifier = modifier, + contentScale = ContentScale.Crop, + ) + } +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt index 6a0ca3e85f..e9af6726f7 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import example.imageviewer.model.TOAST_DURATION import example.imageviewer.style.ImageviewerColors @@ -37,7 +38,7 @@ internal fun Toast( shape = RoundedCornerShape(4.dp) ) { Box(contentAlignment = Alignment.Center) { - Text(value.message) + Text(value.message, color = Color.White) } LaunchedEffect(value.message) { delay(TOAST_DURATION) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Tooltip.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Tooltip.common.kt index e1fa2f7496..0801ad23f7 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Tooltip.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Tooltip.common.kt @@ -1,6 +1,7 @@ package example.imageviewer.view import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier @Composable internal expect fun Tooltip( diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ZoomControllerView.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ZoomControllerView.common.kt new file mode 100644 index 0000000000..0d0aaeef14 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ZoomControllerView.common.kt @@ -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) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/1-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/1-thumbnail.jpg new file mode 100644 index 0000000000..ef99a934ec Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/1-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/1.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/1.jpg new file mode 100644 index 0000000000..18a48cff62 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/1.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/10-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/10-thumbnail.jpg new file mode 100644 index 0000000000..95a3005073 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/10-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/10.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/10.jpg new file mode 100644 index 0000000000..268f7ece86 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/10.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/11-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/11-thumbnail.jpg new file mode 100644 index 0000000000..160df42a6d Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/11-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/11.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/11.jpg new file mode 100644 index 0000000000..19a548d859 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/11.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/12-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/12-thumbnail.jpg new file mode 100644 index 0000000000..6829910d89 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/12-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/12.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/12.jpg new file mode 100644 index 0000000000..57080a02b9 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/12.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/13-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/13-thumbnail.jpg new file mode 100644 index 0000000000..ab0e714c3c Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/13-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/13.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/13.jpg new file mode 100644 index 0000000000..0e18bff48c Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/13.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/14-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/14-thumbnail.jpg new file mode 100644 index 0000000000..327bc311ea Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/14-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/14.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/14.jpg new file mode 100644 index 0000000000..27daf985f5 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/14.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/15-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/15-thumbnail.jpg new file mode 100644 index 0000000000..aa5625d195 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/15-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/15.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/15.jpg new file mode 100644 index 0000000000..61adc55ca9 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/15.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/16-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/16-thumbnail.jpg new file mode 100644 index 0000000000..b86cb3e200 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/16-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/16.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/16.jpg new file mode 100644 index 0000000000..2056836ba1 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/16.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/17-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/17-thumbnail.jpg new file mode 100644 index 0000000000..79f4cd6932 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/17-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/17.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/17.jpg new file mode 100644 index 0000000000..104817cacf Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/17.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/2-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/2-thumbnail.jpg new file mode 100644 index 0000000000..55ec2d3685 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/2-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/2.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/2.jpg new file mode 100644 index 0000000000..62f987d6dc Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/2.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/3-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/3-thumbnail.jpg new file mode 100644 index 0000000000..dfe90a8c5c Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/3-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/3.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/3.jpg new file mode 100644 index 0000000000..2902ae2f2b Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/3.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/4-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/4-thumbnail.jpg new file mode 100644 index 0000000000..5227abef10 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/4-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/4.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/4.jpg new file mode 100644 index 0000000000..d902e5bfc7 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/4.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/5-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/5-thumbnail.jpg new file mode 100644 index 0000000000..8b35fccbc5 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/5-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/5.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/5.jpg new file mode 100644 index 0000000000..aa602f35e4 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/5.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/6-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/6-thumbnail.jpg new file mode 100644 index 0000000000..ae8eea1bb0 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/6-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/6.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/6.jpg new file mode 100644 index 0000000000..fdd9ae604d Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/6.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/7-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/7-thumbnail.jpg new file mode 100644 index 0000000000..ede1618736 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/7-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/7.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/7.jpg new file mode 100644 index 0000000000..bef98a4c16 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/7.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/8-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/8-thumbnail.jpg new file mode 100644 index 0000000000..e5da3dde6d Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/8-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/8.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/8.jpg new file mode 100644 index 0000000000..e720f6cc36 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/8.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/9-thumbnail.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/9-thumbnail.jpg new file mode 100644 index 0000000000..5c937b29ba Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/9-thumbnail.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/9.jpg b/experimental/examples/imageviewer/shared/src/commonMain/resources/9.jpg new file mode 100644 index 0000000000..dd21948ae0 Binary files /dev/null and b/experimental/examples/imageviewer/shared/src/commonMain/resources/9.jpg differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/back.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/back.png deleted file mode 100755 index 206b8d4678..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/back.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_off.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_off.png deleted file mode 100755 index e632616157..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_off.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_on.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_on.png deleted file mode 100755 index 7f5ad81bd6..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/blur_on.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/empty.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/empty.png deleted file mode 100755 index 54e9007671..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/empty.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_off.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_off.png deleted file mode 100755 index 57fbe7891c..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_off.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_on.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_on.png deleted file mode 100755 index ffe1f6102b..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/grayscale_on.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_off.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_off.png deleted file mode 100755 index a41ebfe04e..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_off.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_on.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_on.png deleted file mode 100755 index 1482ff8583..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/pixel_on.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/commonMain/resources/refresh.png b/experimental/examples/imageviewer/shared/src/commonMain/resources/refresh.png deleted file mode 100755 index 3be99c1944..0000000000 Binary files a/experimental/examples/imageviewer/shared/src/commonMain/resources/refresh.png and /dev/null differ diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt new file mode 100644 index 0000000000..641aa97790 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt @@ -0,0 +1,65 @@ +package example.imageviewer + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.graphics.toComposeImageBitmap +import example.imageviewer.filter.scaleBitmapAspectRatio +import example.imageviewer.model.PictureData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val maxStorableImageSizePx = 2000 +private const val storableThumbnailSizePx = 200 + +class DesktopImageStorage( + private val pictures: SnapshotStateList, + private val ioScope: CoroutineScope +) : ImageStorage { + val largeImages = mutableMapOf() + val thumbnails = mutableMapOf() + override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) { + if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) { + return + } + ioScope.launch { + val awtImage = image.imageBitmap.toAwtImage() + + val targetScale = maxOf( + maxStorableImageSizePx.toFloat() / awtImage.width, + maxStorableImageSizePx.toFloat() / awtImage.height + ) + largeImages[picture] = + if (targetScale < 1.0) { + scaleBitmapAspectRatio( + awtImage, + width = (awtImage.width * targetScale).toInt(), + height = (awtImage.height * targetScale).toInt(), + ).toComposeImageBitmap() + } else { + image.imageBitmap + } + + val targetThumbnailScale = maxOf( + storableThumbnailSizePx.toFloat() / awtImage.width, + storableThumbnailSizePx.toFloat() / awtImage.height + ) + thumbnails[picture] = scaleBitmapAspectRatio( + awtImage, + width = (awtImage.width * targetThumbnailScale).toInt(), + height = (awtImage.height * targetThumbnailScale).toInt(), + ).toComposeImageBitmap() + + 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]!! + } + +} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/Localization.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/Localization.desktop.kt new file mode 100644 index 0000000000..a1b305074e --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/Localization.desktop.kt @@ -0,0 +1,7 @@ +package example.imageviewer + +actual fun getCurrentLanguage(): AvailableLanguages = + when (System.getProperties()["user.language"]) { + "de" -> AvailableLanguages.DE + else -> AvailableLanguages.EN + } diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt deleted file mode 100755 index 46eb7b076c..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/R.kt +++ /dev/null @@ -1,53 +0,0 @@ -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("de")) { - appName = "Meine Erinnerungen" - loading = "Bilder werden geladen..." - repoEmpty = "Bildverzeichnis ist leer." - noInternet = "Kein Internetzugriff." - repoInvalid = "Bildverzeichnis beschädigt oder leer." - refreshUnavailable = "Kann Bilder nicht aktualisieren." - loadImageUnavailable = "Kann volles Bild nicht laden." - lastImage = "Dies ist das letzte Bild." - firstImage = "Dies ist das erste Bild." - picture = "Bild:" - size = "Abmessungen:" - pixels = "Pixel." - back = "Zurück" - refresh = "Aktualisieren" - } else { - appName = "My Memories" - 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" - } - } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/filter/BitmapFilter.desktop.kt old mode 100755 new mode 100644 similarity index 59% rename from experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.desktop.kt rename to experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/filter/BitmapFilter.desktop.kt index 0ef02e826f..88880fed35 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/GraphicsMath.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/filter/BitmapFilter.desktop.kt @@ -1,14 +1,32 @@ -package example.imageviewer.utils +package example.imageviewer.filter -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import java.awt.Dimension -import java.awt.Toolkit +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.graphics.toComposeImageBitmap import java.awt.image.BufferedImage import java.awt.image.ConvolveOp import java.awt.image.Kernel -fun scaleBitmapAspectRatio( + +actual fun grayScaleFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyGrayScaleFilter(bitmap.toAwtImage()).toComposeImageBitmap() +} + +actual fun pixelFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyPixelFilter(bitmap.toAwtImage()).toComposeImageBitmap() +} + +actual fun blurFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyBlurFilter(bitmap.toAwtImage()).toComposeImageBitmap() +} + +actual class PlatformContext + +@Composable +internal actual fun getPlatformContext(): PlatformContext = PlatformContext() + +internal fun scaleBitmapAspectRatio( bitmap: BufferedImage, width: Int, height: Int @@ -31,8 +49,7 @@ fun scaleBitmapAspectRatio( return result } -fun applyGrayScaleFilter(bitmap: BufferedImage): BufferedImage { - +private fun applyGrayScaleFilter(bitmap: BufferedImage): BufferedImage { val result = BufferedImage( bitmap.width, bitmap.height, @@ -46,27 +63,23 @@ fun applyGrayScaleFilter(bitmap: BufferedImage): BufferedImage { return result } -fun applyPixelFilter(bitmap: BufferedImage): BufferedImage { - +private fun applyPixelFilter(bitmap: BufferedImage): BufferedImage { val w: Int = bitmap.width val h: Int = bitmap.height - - var result = scaleBitmapAspectRatio(bitmap, w / 4, h / 4) + var result = scaleBitmapAspectRatio(bitmap, w / 12, h / 12) result = scaleBitmapAspectRatio(result, w, h) - return result } -fun applyBlurFilter(bitmap: BufferedImage): BufferedImage { - +private fun applyBlurFilter(bitmap: BufferedImage): BufferedImage { var result = BufferedImage(bitmap.width, bitmap.height, bitmap.type) - val graphics = result.graphics + graphics.drawImage(bitmap, 0, 0, null) graphics.dispose() - val radius = 3 - val size = 3 + val radius = 9 + val size = 9 val weight: Float = 1.0f / (size * size) val matrix = FloatArray(size * size) @@ -85,12 +98,3 @@ fun applyBlurFilter(bitmap: BufferedImage): BufferedImage { result.height - radius * 2 ) } - -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) -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt deleted file mode 100755 index bbd1736347..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toAwtImage -import androidx.compose.ui.graphics.toComposeImageBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyBlurFilter - -class BlurFilter : BitmapFilter { - - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyBlurFilter(bitmap.toAwtImage()).toComposeImageBitmap() - } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt deleted file mode 100755 index bc6c728b21..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toAwtImage -import androidx.compose.ui.graphics.toComposeImageBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyGrayScaleFilter - -class GrayScaleFilter : BitmapFilter { - - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyGrayScaleFilter(bitmap.toAwtImage()).toComposeImageBitmap() - } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt deleted file mode 100755 index e6820310e8..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toAwtImage -import androidx.compose.ui.graphics.toComposeImageBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyPixelFilter - -class PixelFilter : BitmapFilter { - - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyPixelFilter(bitmap.toAwtImage()).toComposeImageBitmap() - } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt index b30c16e487..0eb2dc4123 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt @@ -2,6 +2,19 @@ package example.imageviewer import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import java.util.* actual fun Modifier.notchPadding(): Modifier = Modifier.padding(top = 12.dp) + +class DesktopStorableImage( + val imageBitmap: ImageBitmap +) + +actual typealias PlatformStorableImage = DesktopStorableImage + +actual fun createUUID(): String = UUID.randomUUID().toString() + +actual val ioDispatcher = Dispatchers.IO diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt deleted file mode 100755 index e025d844fe..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Caching.kt +++ /dev/null @@ -1,76 +0,0 @@ -package example.imageviewer.utils - -import example.imageviewer.model.ContentRepository -import example.imageviewer.model.getNameURL -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import java.io.File -import kotlin.math.absoluteValue - -fun ContentRepository.decorateWithDiskCache( - backgroundScope: CoroutineScope, - cacheDir: File -): ContentRepository { - - class FileSystemLock - - val origin = this - val locksCount = 100 - val locks = Array(locksCount) { FileSystemLock() } - - fun getLock(url: String) = locks[url.hashCode().absoluteValue % locksCount] - - return object : ContentRepository { - init { - try { - if (!cacheDir.exists()) { - cacheDir.mkdirs() - } - } catch (t: Throwable) { - t.printStackTrace() - println("Can't create cache dir $cacheDir") - } - } - - override suspend fun loadContent(url: String): ByteArray { - if (!cacheDir.exists()) { - return origin.loadContent(url) - } - val file = cacheDir.resolve("cache-${getNameURL(url)}.png") - val fromCache: ByteArray? = synchronized(getLock(url)) { - if (file.exists()) { - try { - file.readBytes() - } catch (t: Throwable) { - t.printStackTrace() - println("Can't read file $file") - println("Will work without disk cache") - null - } - } else { - null - } - } - - val result = if (fromCache != null) { - fromCache - } else { - val image = origin.loadContent(url) - backgroundScope.launch { - synchronized(getLock(url)) { - // save to cacheDir - try { - file.writeBytes(image) - } catch (t: Throwable) { - println("Can't save image to file $file") - println("Will work without disk cache") - } - } - } - image - } - return result - } - - } -} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt deleted file mode 100644 index 2415ebee55..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/IOScope.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package example.imageviewer.utils - -import kotlinx.coroutines.Dispatchers - -actual val ioDispatcher = Dispatchers.IO \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt index 0051dd3be6..356f43e4fc 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt @@ -1,21 +1,69 @@ package example.imageviewer.view +import androidx.compose.foundation.Image 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.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.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.graphics.Color +import androidx.compose.ui.unit.dp +import example.imageviewer.* +import example.imageviewer.LocalLocalization +import example.imageviewer.createNewPhotoNameAndDescription +import example.imageviewer.model.PictureData +import example.imageviewer.model.createCameraPictureData +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.orEmpty +import org.jetbrains.compose.resources.rememberImageBitmap +import org.jetbrains.compose.resources.resource +import java.util.* +@OptIn(ExperimentalResourceApi::class) @Composable -internal actual fun CameraView(modifier: Modifier) { +internal actual fun CameraView( + modifier: Modifier, + onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit +) { + val randomPicture = remember { resourcePictures.random() } + val imageBitmap = resource(randomPicture.resource).rememberImageBitmap().orEmpty() Box(Modifier.fillMaxSize().background(Color.Black)) { + Image( + bitmap = imageBitmap, + contentDescription = "Camera stub", + Modifier.fillMaxSize() + ) Text( - text = "Camera is not available on Desktop for now.", + text = """ + Camera is not available on Desktop for now. + Instead, we will use a random picture. + """.trimIndent(), color = Color.White, modifier = Modifier.align(Alignment.Center) + .background( + color = Color.Black.copy(alpha = 0.7f), + shape = RoundedCornerShape(10.dp) + ) + .padding(20.dp) ) + val nameAndDescription = createNewPhotoNameAndDescription() + Button(onClick = { + onCapture( + createCameraPictureData( + name = nameAndDescription.name, + description = nameAndDescription.description, + gps = randomPicture.gps + ), + DesktopStorableImage(imageBitmap) + ) + }, Modifier.align(Alignment.BottomCenter)) { + Text(LocalLocalization.current.takePhoto) + } } } diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt index fe5f22f2a9..d1f478bb9e 100755 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt @@ -6,32 +6,25 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import example.imageviewer.* import example.imageviewer.Notification -import example.imageviewer.core.BitmapFilter -import example.imageviewer.core.FilterType import example.imageviewer.model.* -import example.imageviewer.model.filtration.BlurFilter -import example.imageviewer.model.filtration.GrayScaleFilter -import example.imageviewer.model.filtration.PixelFilter import example.imageviewer.style.ImageViewerTheme -import example.imageviewer.utils.decorateWithDiskCache -import example.imageviewer.utils.getPreferredWindowSize -import example.imageviewer.utils.ioDispatcher -import io.ktor.client.* -import io.ktor.client.engine.cio.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import java.io.File +import java.awt.Dimension +import java.awt.Toolkit class ExternalNavigationEventBus { private val _events = MutableSharedFlow( @@ -48,10 +41,12 @@ class ExternalNavigationEventBus { @OptIn(ExperimentalComposeUiApi::class) @Composable fun ApplicationScope.ImageViewerDesktop() { + val ioScope = rememberCoroutineScope { ioDispatcher } val toastState = remember { mutableStateOf(ToastState.Hidden) } - val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher } - val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } val externalNavigationEventBus = remember { ExternalNavigationEventBus() } + val dependencies = remember { + getDependencies(toastState, ioScope, externalNavigationEventBus.events) + } Window( onCloseRequest = ::exitApplication, @@ -72,6 +67,10 @@ fun ApplicationScope.ImageViewerDesktop() { Key.DirectionRight -> externalNavigationEventBus.produceEvent( ExternalImageViewerEvent.Foward ) + + Key.Escape -> externalNavigationEventBus.produceEvent( + ExternalImageViewerEvent.Escape + ) } } false @@ -82,8 +81,7 @@ fun ApplicationScope.ImageViewerDesktop() { modifier = Modifier.fillMaxSize() ) { ImageViewerCommon( - dependencies = dependencies, - externalEvents = externalNavigationEventBus.events + dependencies = dependencies ) Toast(toastState) } @@ -91,51 +89,26 @@ fun ApplicationScope.ImageViewerDesktop() { } } -private fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = - object : Dependencies { - override val ioScope: CoroutineScope = ioScope - override fun getFilter(type: FilterType): BitmapFilter = when (type) { - FilterType.GrayScale -> GrayScaleFilter() - FilterType.Pixel -> PixelFilter() - FilterType.Blur -> BlurFilter() - } - - override val localization: Localization = object : Localization { - override val back: String get() = ResString.back - override val appName: String get() = ResString.appName - override val loading: String get() = ResString.loading - override val repoInvalid: String get() = ResString.repoInvalid - override val repoEmpty: String get() = ResString.repoEmpty - override val noInternet: String get() = ResString.noInternet - override val loadImageUnavailable: String get() = ResString.loadImageUnavailable - override val lastImage: String get() = ResString.lastImage - override val firstImage: String get() = ResString.firstImage - override val picture: String get() = ResString.picture - override val size: String get() = ResString.size - override val pixels: String get() = ResString.pixels - override val refreshUnavailable: String get() = ResString.refreshUnavailable - } - - override val httpClient: HttpClient = HttpClient(CIO) - - val userHome: String? = System.getProperty("user.home") - override val imageRepository: ContentRepository = - createNetworkRepository(httpClient) - .run { - if (userHome != null) { - decorateWithDiskCache( - ioScope, - File(userHome).resolve("Pictures").resolve("imageviewer") - ) - } else { - this - } - } - .adapter { it.toImageBitmap() } - +private fun getDependencies( + toastState: MutableState, + ioScope: CoroutineScope, + events: SharedFlow +) = + object : Dependencies() { override val notification: Notification = object : PopupNotification(localization) { override fun showPopUpMessage(text: String) { toastState.value = ToastState.Shown(text) } } + override val imageStorage: ImageStorage = DesktopImageStorage(pictures, ioScope) + override val externalEvents = events } + +private 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) +} diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt index fd73ffc822..412490275d 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt @@ -4,12 +4,13 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import example.imageviewer.model.GpsPosition import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource @OptIn(ExperimentalResourceApi::class) @Composable -internal actual fun LocationVisualizer(modifier: Modifier) { +internal actual fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) { Image( painter = painterResource("dummy_map.png"), contentDescription = "Map", diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt similarity index 100% rename from experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalaImage.desktop.kt rename to experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScrollableColumn.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScrollableColumn.desktop.kt index d3a7f7961a..fde7e707a5 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScrollableColumn.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScrollableColumn.desktop.kt @@ -1,7 +1,10 @@ package example.imageviewer.view import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.verticalScroll diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt new file mode 100644 index 0000000000..704830cddc --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt @@ -0,0 +1,25 @@ +package example.imageviewer.view + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import example.imageviewer.model.MAX_SCALE +import example.imageviewer.model.MIN_SCALE +import example.imageviewer.model.ScalableState +import example.imageviewer.model.setScale + +@Composable +internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) { + Slider( + modifier = modifier.fillMaxWidth(0.5f).padding(12.dp), + value = scalableState.scale, + valueRange = MIN_SCALE..MAX_SCALE, + onValueChange = { scalableState.setScale(it) }, + colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) + ) +} diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageBitmap.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageBitmap.ios.kt index 75df10bf0d..a04dcda010 100644 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageBitmap.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageBitmap.ios.kt @@ -4,4 +4,5 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import org.jetbrains.skia.Image -actual fun ByteArray.toImageBitmap(): ImageBitmap = Image.makeFromEncoded(this).toComposeImageBitmap() +actual fun ByteArray.toImageBitmap(): ImageBitmap = + Image.makeFromEncoded(this).toComposeImageBitmap() diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt index fd3a012438..6b17e44fe6 100755 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt @@ -2,27 +2,12 @@ package example.imageviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.core.FilterType -import example.imageviewer.model.ContentRepository -import example.imageviewer.model.adapter -import example.imageviewer.model.createNetworkRepository -import example.imageviewer.model.filtration.BlurFilter -import example.imageviewer.model.filtration.GrayScaleFilter -import example.imageviewer.model.filtration.PixelFilter +import example.imageviewer.storage.IosImageStorage import example.imageviewer.style.ImageViewerTheme -import example.imageviewer.utils.ioDispatcher import example.imageviewer.view.Toast import example.imageviewer.view.ToastState -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin import kotlinx.coroutines.CoroutineScope @Composable @@ -43,39 +28,12 @@ internal fun ImageViewerIos() { } } -fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = object : Dependencies { - override val ioScope: CoroutineScope = ioScope - override fun getFilter(type: FilterType): BitmapFilter = when (type) { - FilterType.GrayScale -> GrayScaleFilter() - FilterType.Pixel -> PixelFilter() - FilterType.Blur -> BlurFilter() - } - - override val localization: Localization = object : Localization { - override val appName = "ImageViewer" - override val loading = "Loading images..." - override val repoEmpty = "Repository is empty." - override val noInternet = "No internet access." - override val repoInvalid = "List of images in current repository is invalid or empty." - override val refreshUnavailable = "Cannot refresh images." - override val loadImageUnavailable = "Cannot load full size image." - override val lastImage = "This is last image." - override val firstImage = "This is first image." - override val picture = "Picture:" - override val size = "Size:" - override val pixels = "pixels." - override val back = "Back" - } - - override val httpClient: HttpClient = HttpClient(Darwin) - - override val imageRepository: ContentRepository = - createNetworkRepository(httpClient) - .adapter { it.toImageBitmap() } - - override val notification: Notification = object : PopupNotification(localization) { - override fun showPopUpMessage(text: String) { - toastState.value = ToastState.Shown(text) +fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = + object : Dependencies() { + override val notification: Notification = object : PopupNotification(localization) { + override fun showPopUpMessage(text: String) { + toastState.value = ToastState.Shown(text) + } } + override val imageStorage: ImageStorage = IosImageStorage(pictures, ioScope) } -} diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/Localization.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/Localization.ios.kt new file mode 100644 index 0000000000..f196de35cf --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/Localization.ios.kt @@ -0,0 +1,3 @@ +package example.imageviewer + +actual fun getCurrentLanguage(): AvailableLanguages = AvailableLanguages.EN diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/filter/BitmapFilter.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/filter/BitmapFilter.ios.kt new file mode 100644 index 0000000000..8af0695252 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/filter/BitmapFilter.ios.kt @@ -0,0 +1,96 @@ +package example.imageviewer.filter + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.skia.* + +actual fun grayScaleFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyGrayScaleFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() +} + +actual fun pixelFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyPixelFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() +} + +actual fun blurFilter(bitmap: ImageBitmap, context: PlatformContext): ImageBitmap { + return applyBlurFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() +} + +actual class PlatformContext + +@Composable +internal actual fun getPlatformContext(): PlatformContext = PlatformContext() + +private fun scaleBitmapAspectRatio( + bitmap: Bitmap, + width: Int, + height: Int +): Bitmap { + val boundWidth = width.toFloat() + val boundHeight = height.toFloat() + + val ratioX = boundWidth / bitmap.width + val ratioY = boundHeight / bitmap.height + val ratio = if (ratioX < ratioY) ratioX else ratioY + + val resultWidth = (bitmap.width * ratio).toInt() + val resultHeight = (bitmap.height * ratio).toInt() + + val result = Bitmap().apply { + allocN32Pixels(resultWidth, resultHeight) + } + val canvas = Canvas(result) + canvas.drawImageRect(Image.makeFromBitmap(bitmap), result.bounds.toRect()) + canvas.readPixels(result, 0, 0) + canvas.close() + + return result +} + +private fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { + val imageInfo = ImageInfo( + width = bitmap.width, + height = bitmap.height, + colorInfo = ColorInfo(ColorType.GRAY_8, ColorAlphaType.PREMUL, null) + ) + val result = Bitmap().apply { + allocPixels(imageInfo) + } + + val canvas = Canvas(result) + canvas.drawImageRect(Image.makeFromBitmap(bitmap), bitmap.bounds.toRect()) + canvas.readPixels(result, 0, 0) + canvas.close() + + return result +} + +private fun applyPixelFilter(bitmap: Bitmap): Bitmap { + val width = bitmap.width + val height = bitmap.height + + var result = scaleBitmapAspectRatio(bitmap, width / 12, height / 12) + result = scaleBitmapAspectRatio(result, width, height) + + return result +} + +private fun applyBlurFilter(bitmap: Bitmap): Bitmap { + val result = Bitmap().apply { + allocN32Pixels(bitmap.width, bitmap.height) + } + val blur = Paint().apply { + imageFilter = ImageFilter.makeBlur(12f, 12f, FilterTileMode.CLAMP) + } + + val canvas = Canvas(result) + canvas.saveLayer(null, blur) + canvas.drawImageRect(Image.makeFromBitmap(bitmap), bitmap.bounds.toRect()) + canvas.restore() + canvas.readPixels(result, 0, 0) + canvas.close() + + return result +} diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt deleted file mode 100644 index f0efb262ac..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/BlurFilter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.graphics.asSkiaBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyBlurFilter - -class BlurFilter : BitmapFilter { - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyBlurFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() - } -} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt deleted file mode 100644 index 46c1502833..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/GrayScaleFilter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.graphics.asSkiaBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyGrayScaleFilter - -class GrayScaleFilter : BitmapFilter { - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyGrayScaleFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() - } -} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt deleted file mode 100644 index 8b7dfb4460..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/model/filtration/PixelFilter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package example.imageviewer.model.filtration - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.graphics.asSkiaBitmap -import example.imageviewer.core.BitmapFilter -import example.imageviewer.utils.applyPixelFilter - -class PixelFilter : BitmapFilter { - override fun apply(bitmap: ImageBitmap): ImageBitmap { - return applyPixelFilter(bitmap.asSkiaBitmap()).asComposeImageBitmap() - } -} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt index 4bfb8fefeb..5d9da27204 100644 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt @@ -6,7 +6,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import kotlinx.cinterop.useContents +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import platform.CoreFoundation.CFUUIDCreate +import platform.CoreFoundation.CFUUIDCreateString +import platform.Foundation.CFBridgingRelease import platform.UIKit.UIApplication +import platform.UIKit.UIImage import platform.UIKit.safeAreaInsets private val iosNotchInset = object : WindowInsets { @@ -27,3 +33,14 @@ private val iosNotchInset = object : WindowInsets { actual fun Modifier.notchPadding(): Modifier = this.windowInsetsPadding(iosNotchInset) + +class IosStorableImage( + val rawValue: UIImage +) + +actual typealias PlatformStorableImage = IosStorableImage + +actual fun createUUID(): String = + CFBridgingRelease(CFUUIDCreateString(null, CFUUIDCreate(null))) as String + +actual val ioDispatcher = Dispatchers.IO diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt new file mode 100644 index 0000000000..f411901ddb --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt @@ -0,0 +1,180 @@ +package example.imageviewer.storage + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import example.imageviewer.ImageStorage +import example.imageviewer.PlatformStorableImage +import example.imageviewer.model.PictureData +import kotlinx.cinterop.CValue +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.useContents +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jetbrains.skia.Image +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSize +import platform.CoreGraphics.CGSizeMake +import platform.Foundation.* +import platform.UIKit.* +import platform.posix.memcpy + +private const val maxStorableImageSizePx = 1200 +private const val storableThumbnailSizePx = 180 + +class IosImageStorage( + private val pictures: SnapshotStateList, + private val ioScope: CoroutineScope +) : ImageStorage { + + private val fileManager = NSFileManager.defaultManager + private val savePictureDir = fileManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + create = true, + appropriateForURL = null, + error = null + )!!.URLByAppendingPathComponent("ImageViewer/takenPhotos/")!! + + init { + val directoryContent = fileManager.contentsOfDirectoryAtPath(savePictureDir.path!!, null) + if (directoryContent != null) { + pictures.addAll( + index = 0, + elements = directoryContent.map { it.toString() } + .filter { it.endsWith(".json") } + .map { + val jsonStr = readStringFromFile(it) + Json.Default.decodeFromString(jsonStr) + }.sortedByDescending { + it.timeStampSeconds + } + ) + } else { + fileManager.createDirectoryAtURL(savePictureDir, true, null, null) + } + } + + private fun makeFileUrl(fileName: String) = + savePictureDir.URLByAppendingPathComponent(fileName)!! + + private fun readStringFromFile(fileName: String): String = + NSString.stringWithContentsOfURL( + url = makeFileUrl(fileName), + encoding = NSUTF8StringEncoding, + error = null, + ) as String + + private fun String.writeToFile(fileName: String) = + writeToURL(makeFileUrl(fileName)) + + private fun readPngFromFile(fileName: String) = + NSData.dataWithContentsOfURL(makeFileUrl(fileName)) + + private fun NSData.writeToFile(fileName: String) = + writeToURL(makeFileUrl(fileName), true) + + override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) { + ioScope.launch { + UIImageJPEGRepresentation(image.rawValue.resizeToThumbnail(), 0.6) + ?.writeToFile(picture.thumbnailJpgFile) + pictures.add(0, picture) + UIImageJPEGRepresentation(image.rawValue.resizeToBig(), 0.6) + ?.writeToFile(picture.jpgFile) + val jsonStr = Json.Default.encodeToString(picture) + jsonStr.writeToFile(picture.jsonFile) + } + } + + override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap = + ioScope.async { + val jpgRepresentation = readPngFromFile(picture.thumbnailJpgFile)!! + val byteArray: ByteArray = ByteArray(jpgRepresentation.length.toInt()).apply { + usePinned { + memcpy(it.addressOf(0), jpgRepresentation.bytes, jpgRepresentation.length) + } + } + Image.makeFromEncoded(byteArray).toComposeImageBitmap() + }.await() + + override suspend fun getImage(picture: PictureData.Camera): ImageBitmap = + ioScope.async { + fun getFileContent() = readPngFromFile(picture.jpgFile) + var jpgRepresentation: NSData? = getFileContent() + while (jpgRepresentation == null) { + yield() + jpgRepresentation = getFileContent() + } + val byteArray: ByteArray = ByteArray(jpgRepresentation.length.toInt()).apply { + usePinned { + memcpy(it.addressOf(0), jpgRepresentation.bytes, jpgRepresentation.length) + } + } + Image.makeFromEncoded(byteArray).toComposeImageBitmap() + }.await() + +} + +private fun UIImage.resizeToThumbnail(): UIImage { + val targetScale = maxOf( + storableThumbnailSizePx.toFloat() / size.useContents { width }, + storableThumbnailSizePx.toFloat() / size.useContents { height }, + ) + val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) } + return resize(newSize) +} + +private fun UIImage.resizeToBig(): UIImage { + val targetScale = maxOf( + maxStorableImageSizePx.toFloat() / size.useContents { width }, + maxStorableImageSizePx.toFloat() / size.useContents { height }, + ) + val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) } + return resize(newSize) +} + +private fun UIImage.resize(targetSize: CValue): UIImage { + val currentSize = this.size + val widthRatio = targetSize.useContents { width } / currentSize.useContents { width } + val heightRatio = targetSize.useContents { height } / currentSize.useContents { height } + + val newSize: CValue = if (widthRatio > heightRatio) { + CGSizeMake( + width = currentSize.useContents { width } * heightRatio, + height = currentSize.useContents { height } * heightRatio + ) + } else { + CGSizeMake( + width = currentSize.useContents { width } * widthRatio, + height = currentSize.useContents { height } * widthRatio + ) + } + val newRect = CGRectMake( + x = 0.0, + y = 0.0, + width = newSize.useContents { width }, + height = newSize.useContents { height } + ) + UIGraphicsBeginImageContextWithOptions(size = newSize, opaque = false, scale = 1.0) + this.drawInRect(newRect) + val newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage!! +} + +private val PictureData.Camera.jpgFile get(): String = id + ".jpg" +private val PictureData.Camera.thumbnailJpgFile get(): String = id + "-thumbnail.jpg" +private val PictureData.Camera.jsonFile get(): String = id + ".json" +private fun String.writeToURL(url: NSURL) = (this as NSString).writeToURL( + url = url, + atomically = true, + encoding = NSUTF8StringEncoding, + error = null +) diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/GraphicsMath.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/GraphicsMath.ios.kt deleted file mode 100644 index 2a9fe5c6a3..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/GraphicsMath.ios.kt +++ /dev/null @@ -1,84 +0,0 @@ -package example.imageviewer.utils - -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.Canvas -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ColorInfo -import org.jetbrains.skia.ColorType -import org.jetbrains.skia.FilterTileMode -import org.jetbrains.skia.Image -import org.jetbrains.skia.ImageFilter -import org.jetbrains.skia.ImageInfo -import org.jetbrains.skia.Paint - -fun scaleBitmapAspectRatio( - bitmap: Bitmap, - width: Int, - height: Int -): Bitmap { - val boundWidth = width.toFloat() - val boundHeight = height.toFloat() - - val ratioX = boundWidth / bitmap.width - val ratioY = boundHeight / bitmap.height - val ratio = if (ratioX < ratioY) ratioX else ratioY - - val resultWidth = (bitmap.width * ratio).toInt() - val resultHeight = (bitmap.height * ratio).toInt() - - val result = Bitmap().apply { - allocN32Pixels(resultWidth, resultHeight) - } - val canvas = Canvas(result) - canvas.drawImageRect(Image.makeFromBitmap(bitmap), result.bounds.toRect()) - canvas.readPixels(result, 0, 0) - canvas.close() - - return result -} - -fun applyGrayScaleFilter(bitmap: Bitmap): Bitmap { - val imageInfo = ImageInfo( - width = bitmap.width, - height = bitmap.height, - colorInfo = ColorInfo(ColorType.GRAY_8, ColorAlphaType.PREMUL, null) - ) - val result = Bitmap().apply { - allocPixels(imageInfo) - } - - val canvas = Canvas(result) - canvas.drawImageRect(Image.makeFromBitmap(bitmap), bitmap.bounds.toRect()) - canvas.readPixels(result, 0, 0) - canvas.close() - - return result -} - -fun applyPixelFilter(bitmap: Bitmap): Bitmap { - val width = bitmap.width - val height = bitmap.height - - var result = scaleBitmapAspectRatio(bitmap, width / 4, height / 4) - result = scaleBitmapAspectRatio(result, width, height) - - return result -} - -fun applyBlurFilter(bitmap: Bitmap): Bitmap { - val result = Bitmap().apply { - allocN32Pixels(bitmap.width, bitmap.height) - } - val blur = Paint().apply { - imageFilter = ImageFilter.makeBlur(3f, 3f, FilterTileMode.CLAMP) - } - - val canvas = Canvas(result) - canvas.saveLayer(null, blur) - canvas.drawImageRect(Image.makeFromBitmap(bitmap), bitmap.bounds.toRect()) - canvas.restore() - canvas.readPixels(result, 0, 0) - canvas.close() - - return result -} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt deleted file mode 100644 index 27a2a034df..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/utils/IOScope.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package example.imageviewer.utils - -import kotlinx.coroutines.Dispatchers - -// https://github.com/Kotlin/kotlinx.coroutines/issues/3205 -actual val ioDispatcher = Dispatchers.Default \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt index f614c98c83..e4caad3176 100644 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt @@ -1,23 +1,37 @@ package example.imageviewer.view import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.interop.UIKitInteropView import androidx.compose.ui.unit.dp +import example.imageviewer.IosStorableImage +import example.imageviewer.LocalLocalization +import example.imageviewer.PlatformStorableImage +import example.imageviewer.createNewPhotoNameAndDescription +import example.imageviewer.model.GpsPosition +import example.imageviewer.model.PictureData +import example.imageviewer.model.createCameraPictureData import kotlinx.cinterop.CValue +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.useContents import platform.AVFoundation.* import platform.AVFoundation.AVCaptureDeviceDiscoverySession.Companion.discoverySessionWithDeviceTypes import platform.AVFoundation.AVCaptureDeviceInput.Companion.deviceInputWithDevice import platform.CoreGraphics.CGRect +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.kCLLocationAccuracyBest import platform.Foundation.NSError +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSelectorFromString import platform.QuartzCore.CATransaction import platform.QuartzCore.kCATransactionDisableActions import platform.UIKit.UIDevice @@ -32,8 +46,19 @@ private sealed interface CameraAccess { object Authorized : CameraAccess } +private val deviceTypes = listOf( + AVCaptureDeviceTypeBuiltInWideAngleCamera, + AVCaptureDeviceTypeBuiltInDualWideCamera, + AVCaptureDeviceTypeBuiltInDualCamera, + AVCaptureDeviceTypeBuiltInUltraWideCamera, + AVCaptureDeviceTypeBuiltInDuoCamera +) + @Composable -internal actual fun CameraView(modifier: Modifier) { +internal actual fun CameraView( + modifier: Modifier, + onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit +) { var cameraAccess: CameraAccess by remember { mutableStateOf(CameraAccess.Undefined) } LaunchedEffect(Unit) { when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) { @@ -68,15 +93,54 @@ internal actual fun CameraView(modifier: Modifier) { } CameraAccess.Authorized -> { - AuthorizedCamera() + AuthorizedCamera(onCapture) } } } } @Composable -private fun BoxScope.AuthorizedCamera() { +private fun BoxScope.AuthorizedCamera( + onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit +) { + val camera: AVCaptureDevice? = remember { + discoverySessionWithDeviceTypes( + deviceTypes = deviceTypes, + mediaType = AVMediaTypeVideo, + position = AVCaptureDevicePositionFront, + ).devices.firstOrNull() as? AVCaptureDevice + } + if (camera != null) { + RealDeviceCamera(camera, onCapture) + } else { + Text( + """ + Camera is not available on simulator. + Please try to run on a real iOS device. + """.trimIndent(), color = Color.White + ) + } +} + +@Composable +private fun BoxScope.RealDeviceCamera( + camera: AVCaptureDevice, + onCapture: (picture: PictureData.Camera, image: PlatformStorableImage) -> Unit +) { val capturePhotoOutput = remember { AVCapturePhotoOutput() } + var actualOrientation by remember { + mutableStateOf( + AVCaptureVideoOrientationPortrait + ) + } + val locationManager = remember { + CLLocationManager().apply { + desiredAccuracy = kCLLocationAccuracyBest + requestWhenInUseAuthorization() + } + } + val nameAndDescription = createNewPhotoNameAndDescription() + var capturePhotoStarted by remember { mutableStateOf(false) } val photoCaptureDelegate = remember { object : NSObject(), AVCapturePhotoCaptureDelegateProtocol { override fun captureOutput( @@ -85,38 +149,44 @@ private fun BoxScope.AuthorizedCamera() { error: NSError? ) { val photoData = didFinishProcessingPhoto.fileDataRepresentation() - ?: error("fileDataRepresentation is null") - val uiImage = UIImage(photoData) - //todo pass image to gallery page + if (photoData != null) { + val gps = locationManager.location?.toGps() ?: GpsPosition(0.0, 0.0) + val uiImage = UIImage(photoData) + onCapture( + createCameraPictureData( + name = nameAndDescription.name, + description = nameAndDescription.description, + gps = gps + ), + IosStorableImage(uiImage) + ) + } + capturePhotoStarted = false } } } - val camera: AVCaptureDevice? = remember { - discoverySessionWithDeviceTypes( - deviceTypes = listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), - mediaType = AVMediaTypeVideo, - position = AVCaptureDevicePositionFront, - ).devices.firstOrNull() as? AVCaptureDevice - } - if (camera != null) { - val captureSession: AVCaptureSession = remember { - AVCaptureSession().also { captureSession -> - captureSession.sessionPreset = AVCaptureSessionPresetPhoto - val captureDeviceInput: AVCaptureDeviceInput = - deviceInputWithDevice(device = camera, error = null)!! - captureSession.addInput(captureDeviceInput) - captureSession.addOutput(capturePhotoOutput) - } - } - val cameraPreviewLayer = remember { - AVCaptureVideoPreviewLayer(session = captureSession) + + val captureSession: AVCaptureSession = remember { + AVCaptureSession().also { captureSession -> + captureSession.sessionPreset = AVCaptureSessionPresetPhoto + val captureDeviceInput: AVCaptureDeviceInput = + deviceInputWithDevice(device = camera, error = null)!! + captureSession.addInput(captureDeviceInput) + captureSession.addOutput(capturePhotoOutput) } - UIKitInteropView( - modifier = Modifier.fillMaxSize(), - background = Color.Black, - resize = { view: UIView, rect: CValue -> - cameraPreviewLayer.connection?.apply { - videoOrientation = when (UIDevice.currentDevice.orientation) { + } + val cameraPreviewLayer = remember { + AVCaptureVideoPreviewLayer(session = captureSession) + } + + DisposableEffect(Unit) { + class OrientationListener : NSObject() { + @Suppress("UNUSED_PARAMETER") + @ObjCAction + fun orientationDidChange(arg: NSNotification) { + val cameraConnection = cameraPreviewLayer.connection + if (cameraConnection != null) { + actualOrientation = when (UIDevice.currentDevice.orientation) { UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait @@ -127,50 +197,85 @@ private fun BoxScope.AuthorizedCamera() { AVCaptureVideoOrientationLandscapeLeft UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> - AVCaptureVideoOrientationPortraitUpsideDown + AVCaptureVideoOrientationPortrait - else -> videoOrientation + else -> cameraConnection.videoOrientation } + cameraConnection.videoOrientation = actualOrientation } - CATransaction.begin() - CATransaction.setValue(true, kCATransactionDisableActions) - view.layer.setFrame(rect) - cameraPreviewLayer.setFrame(rect) - CATransaction.commit() - }, - ) { - val cameraContainer = UIView() - cameraContainer.layer.addSublayer(cameraPreviewLayer) - cameraPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill - captureSession.startRunning() - cameraContainer + capturePhotoOutput.connectionWithMediaType(AVMediaTypeVideo) + ?.videoOrientation = actualOrientation + } } - Button( - modifier = Modifier.align(Alignment.BottomCenter).padding(20.dp), - onClick = { - val photoSettings = AVCapturePhotoSettings.photoSettingsWithFormat( - format = mapOf(AVVideoCodecKey to AVVideoCodecTypeJPEG) - ) - photoSettings.setHighResolutionPhotoEnabled(true) - capturePhotoOutput.setHighResolutionCaptureEnabled(true) - capturePhotoOutput.capturePhotoWithSettings( - settings = photoSettings, - delegate = photoCaptureDelegate - ) - }) { - Text("Compose Button - take a photo 📸") + + val listener = OrientationListener() + val notificationName = platform.UIKit.UIDeviceOrientationDidChangeNotification + NSNotificationCenter.defaultCenter.addObserver( + observer = listener, + selector = NSSelectorFromString( + OrientationListener::orientationDidChange.name + ":" + ), + name = notificationName, + `object` = null + ) + onDispose { + NSNotificationCenter.defaultCenter.removeObserver( + observer = listener, + name = notificationName, + `object` = null + ) } - } else { - SimulatorStub() + } + UIKitInteropView( + modifier = Modifier.fillMaxSize(), + background = Color.Black, + resize = { view: UIView, rect: CValue -> + CATransaction.begin() + CATransaction.setValue(true, kCATransactionDisableActions) + view.layer.setFrame(rect) + cameraPreviewLayer.setFrame(rect) + CATransaction.commit() + }, + ) { + val cameraContainer = UIView() + cameraContainer.layer.addSublayer(cameraPreviewLayer) + cameraPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill + captureSession.startRunning() + cameraContainer + } + Button( + modifier = Modifier.align(Alignment.BottomCenter).padding(44.dp), + enabled = !capturePhotoStarted, + onClick = { + capturePhotoStarted = true + val photoSettings = AVCapturePhotoSettings.photoSettingsWithFormat( + format = mapOf(AVVideoCodecKey to AVVideoCodecTypeJPEG) + ) + if (camera.position == AVCaptureDevicePositionFront) { + capturePhotoOutput.connectionWithMediaType(AVMediaTypeVideo) + ?.automaticallyAdjustsVideoMirroring = false + capturePhotoOutput.connectionWithMediaType(AVMediaTypeVideo) + ?.videoMirrored = true + } + capturePhotoOutput.capturePhotoWithSettings( + settings = photoSettings, + delegate = photoCaptureDelegate + ) + } + ) { + Text(LocalLocalization.current.takePhoto) + } + if (capturePhotoStarted) { + CircularProgressIndicator( + modifier = Modifier.size(80.dp).align(Alignment.Center), + color = Color.White.copy(alpha = 0.7f), + strokeWidth = 8.dp, + ) } } -@Composable -private fun SimulatorStub() { - Text( - """ - Camera is not available on simulator. - Please try to run on a real iOS device. - """.trimIndent(), color = Color.White +fun CLLocation.toGps() = + GpsPosition( + latitude = coordinate.useContents { latitude }, + longitude = coordinate.useContents { longitude } ) -} diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt index 93add685f2..aaa46d4545 100644 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt @@ -3,27 +3,33 @@ package example.imageviewer.view import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitInteropView +import example.imageviewer.model.GpsPosition import platform.CoreLocation.CLLocationCoordinate2DMake import platform.MapKit.MKCoordinateRegionMakeWithDistance import platform.MapKit.MKMapView import platform.MapKit.MKPointAnnotation @Composable -internal actual fun LocationVisualizer(modifier: Modifier) { - //todo get real geo coordinates +internal actual fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) { UIKitInteropView( modifier = modifier, factory = { val mkMapView = MKMapView() - val cityAmsterdam = CLLocationCoordinate2DMake(52.3676, 4.9041) + val cityAmsterdam = CLLocationCoordinate2DMake(gps.latitude, gps.longitude) mkMapView.setRegion( MKCoordinateRegionMakeWithDistance( centerCoordinate = cityAmsterdam, - 5000.0, 5000.0 + 10_000.0, 10_000.0 ), animated = false ) - mkMapView.addAnnotation(MKPointAnnotation(cityAmsterdam, title = null, subtitle = null)) + mkMapView.addAnnotation( + MKPointAnnotation( + cityAmsterdam, + title = title, + subtitle = null + ) + ) mkMapView }, ) diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/ZoomControllerView.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/ZoomControllerView.ios.kt new file mode 100644 index 0000000000..fd94ebab81 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/ZoomControllerView.ios.kt @@ -0,0 +1,10 @@ +package example.imageviewer.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import example.imageviewer.model.ScalableState + +@Composable +internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) { + // No need for additional ZoomControllerView for iOS +}