Browse Source

ImageViewer delete, edit and share memories (#2957)

pull/2968/head
dima.avdeev 2 years ago committed by GitHub
parent
commit
2cea802cec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      experimental/examples/imageviewer/shared/build.gradle.kts
  2. 11
      experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml
  3. 6
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/ImageViewerFileProvider.kt
  4. 8
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt
  5. 69
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/storage/AndroidImageStorage.kt
  6. 77
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/EditMemoryDialog.android.kt
  7. 27
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt
  8. 3
      experimental/examples/imageviewer/shared/src/androidMain/res/xml/file_paths.xml
  9. 59
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt
  10. 10
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt
  11. 4
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt
  12. 6
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PictureData.kt
  13. 6
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt
  14. 6
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt
  15. 11
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/EditMemoryDialog.common.kt
  16. 106
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt
  17. 24
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt
  18. 8
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt
  19. 87
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/EditMemoryDialog.desktop.kt
  20. 9
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt
  21. 37
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt
  22. 41
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/IosShareIcon.kt
  23. 5
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/main.ios.kt
  24. 5
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt
  25. 4
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/FileExtensions.kt
  26. 48
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt
  27. 77
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/EditMemoryDialog.ios.kt
  28. 28
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt

1
experimental/examples/imageviewer/shared/build.gradle.kts

@ -35,6 +35,7 @@ kotlin {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
//implementation(compose.materialIconsExtended) // TODO not working on iOS
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")

11
experimental/examples/imageviewer/shared/src/androidMain/AndroidManifest.xml

@ -1,7 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="example.imageviewer.shared">
package="example.imageviewer.shared">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application>
<provider
android:name="example.imageviewer.ImageViewerFileProvider"
android:authorities="example.imageviewer.fileprovider"
android:exported="false"
android:grantUriPermissions="true"></provider>
</application>
</manifest>

6
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/ImageViewerFileProvider.kt

@ -0,0 +1,6 @@
package example.imageviewer
import androidx.core.content.FileProvider
import example.imageviewer.shared.R
class ImageViewerFileProvider : FileProvider(R.xml.file_paths)

8
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/platform.android.kt

@ -4,8 +4,12 @@ import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import example.imageviewer.model.PictureData
import kotlinx.coroutines.Dispatchers
import java.util.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.ui.graphics.vector.ImageVector
actual fun Modifier.notchPadding(): Modifier = displayCutoutPadding().statusBarsPadding()
@ -18,3 +22,7 @@ actual typealias PlatformStorableImage = AndroidStorableImage
actual fun createUUID(): String = UUID.randomUUID().toString()
actual val ioDispatcher = Dispatchers.IO
actual val isShareFeatureSupported: Boolean = true
actual val shareIcon: ImageVector = Icons.Filled.Share

69
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/storage/AndroidImageStorage.kt

@ -2,23 +2,29 @@ package example.imageviewer.storage
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
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.content.FileProvider
import androidx.core.graphics.scale
import example.imageviewer.ImageStorage
import example.imageviewer.PlatformStorableImage
import example.imageviewer.model.PictureData
import example.imageviewer.toAndroidBitmap
import example.imageviewer.toImageBitmap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.resource
import java.io.File
import java.util.UUID
private const val maxStorableImageSizePx = 2000
private const val storableThumbnailSizePx = 200
@ -29,10 +35,15 @@ class AndroidImageStorage(
private val ioScope: CoroutineScope,
context: Context
) : ImageStorage {
private val savePictureDir = File(context.filesDir, "takenPhotos")
private val savePictureDir = File(context.filesDir, "taken_photos")
private val sharedImagesDir = File(context.filesDir, "share_images")
private val PictureData.Camera.jpgFile get() = File(savePictureDir, "$id.jpg")
private val PictureData.Camera.thumbnailJpgFile get() = File(savePictureDir, "$id-thumbnail.jpg")
private val PictureData.Camera.thumbnailJpgFile
get() = File(
savePictureDir,
"$id-thumbnail.jpg"
)
private val PictureData.Camera.jsonFile get() = File(savePictureDir, "$id.json")
init {
@ -53,30 +64,64 @@ class AndroidImageStorage(
}
}
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) {
return
}
ioScope.launch {
with(image.imageBitmap) {
pictureData.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
pictureData.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
picture.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
picture.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
}
pictures.add(0, pictureData)
pictureData.jsonFile.writeText(pictureData.toJson())
pictures.add(0, picture)
picture.jsonFile.writeText(picture.toJson())
}
}
override fun delete(picture: PictureData.Camera) {
ioScope.launch {
picture.jsonFile.delete()
picture.jpgFile.delete()
picture.thumbnailJpgFile.delete()
}
}
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap =
override fun rewrite(picture: PictureData.Camera) {
ioScope.launch {
picture.jsonFile.delete()
picture.jsonFile.writeText(picture.toJson())
}
}
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.thumbnailJpgFile.readBytes().toImageBitmap()
picture.thumbnailJpgFile.readBytes().toImageBitmap()
}
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap =
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.jpgFile.readBytes().toImageBitmap()
picture.jpgFile.readBytes().toImageBitmap()
}
@OptIn(ExperimentalResourceApi::class)
suspend fun getUri(context: Context, picture: PictureData): Uri = withContext(Dispatchers.IO) {
val tempFileToShare: File = sharedImagesDir.resolve("share_picture.jpg")
when (picture) {
is PictureData.Camera -> {
picture.jpgFile.copyTo(tempFileToShare, overwrite = true)
}
is PictureData.Resource -> {
tempFileToShare.writeBytes(resource(picture.resource).readBytes())
}
}
FileProvider.getUriForFile(
context,
"example.imageviewer.fileprovider",
tempFileToShare
)
}
}
private fun ImageBitmap.fitInto(px: Int): ImageBitmap {

77
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/EditMemoryDialog.android.kt

@ -0,0 +1,77 @@
package example.imageviewer.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal actual fun BoxScope.EditMemoryDialog(
previousName: String,
previousDescription: String,
save: (name: String, description: String) -> Unit
) {
var name by remember { mutableStateOf(previousName) }
var description by remember { mutableStateOf(previousDescription) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
save(name, description)
}
) {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(30.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
TextField(
value = description,
onValueChange = { description = it },
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
}
}
}

27
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt

@ -1,15 +1,22 @@
package example.imageviewer.view
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import example.imageviewer.*
import example.imageviewer.filter.PlatformContext
import example.imageviewer.model.PictureData
import example.imageviewer.storage.AndroidImageStorage
import example.imageviewer.style.ImageViewerTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ImageViewerAndroid() {
@ -27,5 +34,23 @@ private fun getDependencies(context: Context, ioScope: CoroutineScope) = object
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
}
override val imageStorage: ImageStorage = AndroidImageStorage(pictures, ioScope, context)
override val imageStorage: AndroidImageStorage = AndroidImageStorage(pictures, ioScope, context)
override val sharePicture: SharePicture = object : SharePicture {
override fun share(context: PlatformContext, picture: PictureData) {
ioScope.launch {
val shareIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_STREAM,
imageStorage.getUri(context.androidContext, picture)
)
type = "image/jpeg"
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
withContext(Dispatchers.Main) {
context.androidContext.startActivity(Intent.createChooser(shareIntent, null))
}
}
}
}
}

3
experimental/examples/imageviewer/shared/src/androidMain/res/xml/file_paths.xml

@ -0,0 +1,3 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_images" path="share_images/"/>
</paths>

59
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt

@ -4,6 +4,7 @@ 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.filter.PlatformContext
import example.imageviewer.model.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
@ -14,6 +15,7 @@ import org.jetbrains.compose.resources.resource
abstract class Dependencies {
abstract val notification: Notification
abstract val imageStorage: ImageStorage
abstract val sharePicture: SharePicture
val pictures: SnapshotStateList<PictureData> = mutableStateListOf(*resourcePictures)
open val externalEvents: Flow<ExternalImageViewerEvent> = emptyFlow()
val localization: Localization = getCurrentLocalization()
@ -37,6 +39,40 @@ abstract class Dependencies {
imageStorage.getThumbnail(picture)
}
}
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
imageStorage.saveImage(picture, image)
}
override fun delete(picture: PictureData) {
pictures.remove(picture)
if (picture is PictureData.Camera) {
imageStorage.delete(picture)
}
}
override fun edit(picture: PictureData, name: String, description: String): PictureData {
when (picture) {
is PictureData.Resource -> {
val edited = picture.copy(
name = name,
description = description,
)
pictures[pictures.indexOf(picture)] = edited
return edited
}
is PictureData.Camera -> {
val edited = picture.copy(
name = name,
description = description,
)
pictures[pictures.indexOf(picture)] = edited
imageStorage.rewrite(edited)
return edited
}
}
}
}
}
@ -66,12 +102,21 @@ interface Localization {
interface ImageProvider {
suspend fun getImage(picture: PictureData): ImageBitmap
suspend fun getThumbnail(picture: PictureData): ImageBitmap
fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage)
fun delete(picture: PictureData)
fun edit(picture: PictureData, name: String, description: String): PictureData
}
interface ImageStorage {
fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage)
suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap
suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap
fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage)
fun delete(picture: PictureData.Camera)
fun rewrite(picture: PictureData.Camera)
suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap
suspend fun getImage(picture: PictureData.Camera): ImageBitmap
}
interface SharePicture {
fun share(context: PlatformContext, picture: PictureData)
}
internal val LocalLocalization = staticCompositionLocalOf<Localization> {
@ -86,14 +131,14 @@ internal val LocalImageProvider = staticCompositionLocalOf<ImageProvider> {
noLocalProvidedFor("LocalImageProvider")
}
internal val LocalImageStorage = staticCompositionLocalOf<ImageStorage> {
noLocalProvidedFor("LocalImageStorage")
}
internal val LocalInternalEvents = staticCompositionLocalOf<Flow<ExternalImageViewerEvent>> {
noLocalProvidedFor("LocalInternalEvents")
}
internal val LocalSharePicture = staticCompositionLocalOf<SharePicture> {
noLocalProvidedFor("LocalSharePicture")
}
private fun noLocalProvidedFor(name: String): Nothing {
error("CompositionLocal $name not present")
}

10
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt

@ -21,8 +21,8 @@ internal fun ImageViewerCommon(
LocalLocalization provides dependencies.localization,
LocalNotification provides dependencies.notification,
LocalImageProvider provides dependencies.imageProvider,
LocalImageStorage provides dependencies.imageStorage,
LocalInternalEvents provides dependencies.externalEvents
LocalInternalEvents provides dependencies.externalEvents,
LocalSharePicture provides dependencies.sharePicture,
) {
ImageViewerWithProvidedDependencies(dependencies.pictures)
}
@ -63,7 +63,7 @@ internal fun ImageViewerWithProvidedDependencies(
pictures = pictures,
selectedPictureIndex = selectedPictureIndex,
onClickPreviewPicture = { previewPictureId ->
navigationStack.push(MemoryPage(previewPictureId))
navigationStack.push(MemoryPage(mutableStateOf(previewPictureId)))
}
) {
navigationStack.push(CameraPage())
@ -83,8 +83,8 @@ internal fun ImageViewerWithProvidedDependencies(
MemoryScreen(
pictures = pictures,
memoryPage = page,
onSelectRelatedMemory = { galleryId ->
navigationStack.push(MemoryPage(galleryId))
onSelectRelatedMemory = { picture ->
navigationStack.push(MemoryPage(mutableStateOf(picture)))
},
onBack = {
navigationStack.back()

4
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Page.kt

@ -1,8 +1,10 @@
package example.imageviewer.model
import androidx.compose.runtime.MutableState
sealed interface Page
class MemoryPage(val picture: PictureData) : Page
class MemoryPage(val pictureState: MutableState<PictureData>) : Page
class CameraPage : Page
class FullScreenPage(val picture: PictureData) : Page
class GalleryPage : Page

6
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/PictureData.kt

@ -19,7 +19,7 @@ sealed interface PictureData {
val gps: GpsPosition
val dateString: String
class Resource(
data class Resource(
val resource: String,
val thumbnailResource: String,
override val name: String,
@ -29,15 +29,13 @@ sealed interface PictureData {
) : PictureData
@Serializable
class Camera(
data 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)

6
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt

@ -1,6 +1,8 @@
package example.imageviewer
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import example.imageviewer.model.PictureData
import kotlinx.coroutines.CoroutineDispatcher
expect fun Modifier.notchPadding(): Modifier
@ -10,3 +12,7 @@ expect class PlatformStorableImage
expect fun createUUID(): String
expect val ioDispatcher: CoroutineDispatcher
expect val isShareFeatureSupported: Boolean
expect val shareIcon: ImageVector

6
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt

@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import example.imageviewer.LocalImageStorage
import example.imageviewer.LocalImageProvider
import kotlinx.coroutines.delay
@Composable
internal fun CameraScreen(onBack: (resetSelectedPicture: Boolean) -> Unit) {
val storage = LocalImageStorage.current
val imageProvider = LocalImageProvider.current
var showCamera by remember { mutableStateOf(false) }
LaunchedEffect(onBack) {
if (!showCamera) {
@ -22,7 +22,7 @@ internal fun CameraScreen(onBack: (resetSelectedPicture: Boolean) -> Unit) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
if (showCamera) {
CameraView(Modifier.fillMaxSize(), onCapture = { picture, image ->
storage.saveImage(picture, image)
imageProvider.saveImage(picture, image)
onBack(true)
})
}

11
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/EditMemoryDialog.common.kt

@ -0,0 +1,11 @@
package example.imageviewer.view
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
@Composable
internal expect fun BoxScope.EditMemoryDialog(
previousName: String,
previousDescription: String,
save: (name: String, description: String) -> Unit
)

106
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt

@ -10,6 +10,9 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
@ -20,18 +23,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
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.LocalImageProvider
import example.imageviewer.LocalSharePicture
import example.imageviewer.filter.getPlatformContext
import example.imageviewer.isShareFeatureSupported
import example.imageviewer.model.*
import example.imageviewer.shareIcon
import example.imageviewer.style.ImageviewerColors
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalResourceApi::class)
@Composable
internal fun MemoryScreen(
pictures: SnapshotStateList<PictureData>,
@ -41,9 +48,13 @@ internal fun MemoryScreen(
onHeaderClick: (PictureData) -> Unit,
) {
val imageProvider = LocalImageProvider.current
var headerImage: ImageBitmap? by remember(memoryPage.picture) { mutableStateOf(null) }
LaunchedEffect(memoryPage.picture) {
headerImage = imageProvider.getImage(memoryPage.picture)
val sharePicture = LocalSharePicture.current
var edit: Boolean by remember { mutableStateOf(false) }
val picture = memoryPage.pictureState.value
var headerImage: ImageBitmap? by remember(picture) { mutableStateOf(null) }
val platformContext = getPlatformContext()
LaunchedEffect(picture) {
headerImage = imageProvider.getImage(picture)
}
Box {
val scrollState = rememberScrollState()
@ -65,17 +76,20 @@ internal fun MemoryScreen(
headerImage?.let {
MemoryHeader(
it,
picture = memoryPage.picture,
onClick = { onHeaderClick(memoryPage.picture) }
picture = picture,
onClick = { onHeaderClick(picture) }
)
}
}
Box(modifier = Modifier.background(MaterialTheme.colors.background)) {
Column {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Headliner("Note")
Collapsible(memoryPage.picture.description)
Collapsible(picture.description)
Headliner("Related memories")
RelatedMemoriesVisualizer(pictures, onSelectRelatedMemory)
RelatedMemoriesVisualizer(
pictures = remember { (pictures - picture).shuffled().take(8) },
onSelectRelatedMemory = onSelectRelatedMemory
)
Headliner("Place")
val locationShape = RoundedCornerShape(10.dp)
LocationVisualizer(
@ -84,30 +98,23 @@ internal fun MemoryScreen(
.border(1.dp, Color.Gray, locationShape)
.fillMaxWidth()
.height(200.dp),
gps = memoryPage.picture.gps,
title = memoryPage.picture.name,
gps = picture.gps,
title = picture.name,
)
Spacer(Modifier.height(50.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
8.dp,
Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painterResource("trash.png"),
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Text(
text = "Delete Memory",
textAlign = TextAlign.Left,
color = ImageviewerColors.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Normal
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
IconWithText(Icons.Default.Delete, "Delete") {
imageProvider.delete(picture)
onBack()
}
IconWithText(Icons.Default.Edit, "Edit") {
edit = true
}
if (isShareFeatureSupported) {
IconWithText(shareIcon, "Share") {
sharePicture.share(platformContext, picture)
}
}
}
Spacer(Modifier.height(50.dp))
}
@ -119,6 +126,39 @@ internal fun MemoryScreen(
},
alignRightContent = {},
)
if (edit) {
EditMemoryDialog(picture.name, picture.description) { name, description ->
val edited = imageProvider.edit(picture, name, description)
memoryPage.pictureState.value = edited
edit = false
}
}
}
}
@Composable
private fun IconWithText(icon: ImageVector, text: String, onClick: () -> Unit) {
Row(
modifier = Modifier.clickable {
onClick()
},
horizontalArrangement = Arrangement.spacedBy(
8.dp,
Alignment.CenterHorizontally
),
verticalAlignment = Alignment.Bottom
) {
Icon(
imageVector = icon,
contentDescription = text,
)
Text(
text = text,
textAlign = TextAlign.Left,
color = ImageviewerColors.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Normal
)
}
}
@ -222,7 +262,7 @@ internal fun Headliner(s: String) {
@Composable
internal fun RelatedMemoriesVisualizer(
ps: List<PictureData>,
pictures: List<PictureData>,
onSelectRelatedMemory: (PictureData) -> Unit
) {
Box(
@ -232,7 +272,7 @@ internal fun RelatedMemoriesVisualizer(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(ps) { idx, item ->
itemsIndexed(pictures) { idx, item ->
RelatedMemory(item, onSelectRelatedMemory)
}
}

24
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt

@ -20,23 +20,31 @@ class DesktopImageStorage(
private val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>()
private val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>()
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) {
return
}
ioScope.launch {
largeImages[pictureData] = image.imageBitmap.fitInto(maxStorableImageSizePx)
thumbnails[pictureData] = image.imageBitmap.fitInto(storableThumbnailSizePx)
pictures.add(0, pictureData)
largeImages[picture] = image.imageBitmap.fitInto(maxStorableImageSizePx)
thumbnails[picture] = image.imageBitmap.fitInto(storableThumbnailSizePx)
pictures.add(0, picture)
}
}
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap {
return thumbnails[pictureData]!!
override fun delete(picture: PictureData.Camera) {
// For now, on Desktop pictures saving in memory. We don't need additional delete logic.
}
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap {
return largeImages[pictureData]!!
override fun rewrite(picture: PictureData.Camera) {
// For now, on Desktop pictures saving in memory. We don't need additional rewrite logic.
}
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap {
return thumbnails[picture]!!
}
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap {
return largeImages[picture]!!
}
}

8
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/platform.desktop.kt

@ -1,9 +1,13 @@
package example.imageviewer
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import example.imageviewer.model.PictureData
import kotlinx.coroutines.Dispatchers
import java.util.*
@ -18,3 +22,7 @@ actual typealias PlatformStorableImage = DesktopStorableImage
actual fun createUUID(): String = UUID.randomUUID().toString()
actual val ioDispatcher = Dispatchers.IO
actual val isShareFeatureSupported: Boolean = false
actual val shareIcon: ImageVector = Icons.Filled.Share

87
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/EditMemoryDialog.desktop.kt

@ -0,0 +1,87 @@
package example.imageviewer.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal actual fun BoxScope.EditMemoryDialog(
previousName: String,
previousDescription: String,
save: (name: String, description: String) -> Unit
) {
var name by remember { mutableStateOf(previousName) }
var description by remember { mutableStateOf(previousDescription) }
AlertDialog(
onDismissRequest = {
save(name, description)
},
buttons = {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(30.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
TextField(
value = description,
onValueChange = { description = it },
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
}
},
)
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
}
) {
}
}

9
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ImageViewer.desktop.kt

@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import example.imageviewer.*
import example.imageviewer.Notification
import example.imageviewer.filter.PlatformContext
import example.imageviewer.model.*
import example.imageviewer.style.ImageViewerTheme
import kotlinx.coroutines.CoroutineScope
@ -23,6 +24,7 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.awt.Desktop
import java.awt.Dimension
import java.awt.Toolkit
@ -100,7 +102,12 @@ private fun getDependencies(
toastState.value = ToastState.Shown(text)
}
}
override val imageStorage: ImageStorage = DesktopImageStorage(pictures, ioScope)
override val imageStorage: DesktopImageStorage = DesktopImageStorage(pictures, ioScope)
override val sharePicture: SharePicture = object : SharePicture {
override fun share(context: PlatformContext, picture: PictureData) {
// On Desktop share feature not supported
}
}
override val externalEvents = events
}

37
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/ImageViewer.ios.kt

@ -4,17 +4,28 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import example.imageviewer.filter.PlatformContext
import example.imageviewer.model.PictureData
import example.imageviewer.storage.IosImageStorage
import example.imageviewer.style.ImageViewerTheme
import example.imageviewer.view.Toast
import example.imageviewer.view.ToastState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
import platform.UIKit.UIImage
import platform.UIKit.UIWindow
@Composable
internal fun ImageViewerIos() {
val toastState = remember { mutableStateOf<ToastState>(ToastState.Hidden) }
val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher }
val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) }
val dependencies = remember(ioScope) {
getDependencies(ioScope, toastState)
}
ImageViewerTheme {
Surface(
@ -35,5 +46,27 @@ fun getDependencies(ioScope: CoroutineScope, toastState: MutableState<ToastState
toastState.value = ToastState.Shown(text)
}
}
override val imageStorage: ImageStorage = IosImageStorage(pictures, ioScope)
override val imageStorage: IosImageStorage = IosImageStorage(pictures, ioScope)
override val sharePicture: SharePicture = object : SharePicture {
override fun share(context: PlatformContext, picture: PictureData) {
ioScope.launch {
val data = imageStorage.getNSDataToShare(picture)
withContext(Dispatchers.Main) {
val window = UIApplication.sharedApplication.windows.last() as? UIWindow
val currentViewController = window?.rootViewController
val activityViewController = UIActivityViewController(
activityItems = listOf(UIImage(data = data)),
applicationActivities = null
)
currentViewController?.presentViewController(
viewControllerToPresent = activityViewController,
animated = true,
completion = null,
)
}
}
}
}
}

41
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/IosShareIcon.kt

@ -0,0 +1,41 @@
package example.imageviewer
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
// TODO Copied from material3, because "material:material-icons-extended" not working on iOS for now
val IosShareIcon: ImageVector =
materialIcon(name = "Filled.IosShare") {
materialPath {
moveTo(16.0f, 5.0f)
lineToRelative(-1.42f, 1.42f)
lineToRelative(-1.59f, -1.59f)
lineTo(12.99f, 16.0f)
horizontalLineToRelative(-1.98f)
lineTo(11.01f, 4.83f)
lineTo(9.42f, 6.42f)
lineTo(8.0f, 5.0f)
lineToRelative(4.0f, -4.0f)
lineToRelative(4.0f, 4.0f)
close()
moveTo(20.0f, 10.0f)
verticalLineToRelative(11.0f)
curveToRelative(0.0f, 1.1f, -0.9f, 2.0f, -2.0f, 2.0f)
lineTo(6.0f, 23.0f)
curveToRelative(-1.11f, 0.0f, -2.0f, -0.9f, -2.0f, -2.0f)
lineTo(4.0f, 10.0f)
curveToRelative(0.0f, -1.11f, 0.89f, -2.0f, 2.0f, -2.0f)
horizontalLineToRelative(3.0f)
verticalLineToRelative(2.0f)
lineTo(6.0f, 10.0f)
verticalLineToRelative(11.0f)
horizontalLineToRelative(12.0f)
lineTo(18.0f, 10.0f)
horizontalLineToRelative(-3.0f)
lineTo(15.0f, 8.0f)
horizontalLineToRelative(3.0f)
curveToRelative(1.1f, 0.0f, 2.0f, 0.89f, 2.0f, 2.0f)
close()
}
}

5
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/main.ios.kt

@ -3,5 +3,8 @@ package example.imageviewer
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
fun MainViewController(): UIViewController = ComposeUIViewController { ImageViewerIos() }
fun MainViewController(): UIViewController =
ComposeUIViewController {
ImageViewerIos()
}

5
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/platform.ios.kt

@ -3,6 +3,7 @@ package example.imageviewer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlinx.cinterop.useContents
@ -44,3 +45,7 @@ actual fun createUUID(): String =
CFBridgingRelease(CFUUIDCreateString(null, CFUUIDCreate(null))) as String
actual val ioDispatcher = Dispatchers.IO
actual val isShareFeatureSupported: Boolean = true
actual val shareIcon: ImageVector = IosShareIcon

4
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/FileExtensions.kt

@ -41,6 +41,10 @@ fun NSURL.listFiles(filter: (NSURL, String) -> Boolean) =
?.map { File(this, it) }
?.toTypedArray()
fun NSURL.delete() {
NSFileManager.defaultManager.removeItemAtURL(this, null)
}
suspend fun NSURL.readData(): NSData {
while (true) {
val data = NSData.dataWithContentsOfURL(this)

48
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt

@ -56,26 +56,56 @@ class IosImageStorage(
}
}
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
ioScope.launch {
with(image.rawValue) {
pictureData.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
pictureData.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
picture.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
picture.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
}
pictures.add(0, pictureData)
pictureData.jsonFile.writeText(pictureData.toJson())
pictures.add(0, picture)
picture.jsonFile.writeText(picture.toJson())
}
}
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap =
override fun delete(picture: PictureData.Camera) {
ioScope.launch {
picture.jsonFile.delete()
picture.jpgFile.delete()
picture.thumbnailJpgFile.delete()
}
}
override fun rewrite(picture: PictureData.Camera) {
ioScope.launch {
picture.jsonFile.delete()
picture.jsonFile.writeText(picture.toJson())
}
}
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.thumbnailJpgFile.readBytes().toImageBitmap()
picture.thumbnailJpgFile.readBytes().toImageBitmap()
}
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap =
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.jpgFile.readBytes().toImageBitmap()
picture.jpgFile.readBytes().toImageBitmap()
}
suspend fun getNSDataToShare(picture: PictureData): NSData = withContext(Dispatchers.IO) {
when (picture) {
is PictureData.Camera -> {
picture.jpgFile
}
is PictureData.Resource -> {
NSURL(
fileURLWithPath = NSBundle.mainBundle.resourcePath + "/" + picture.resource,
isDirectory = false
)
}
}.readData()
}
}
private fun UIImage.fitInto(px: Int): UIImage {

77
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/EditMemoryDialog.ios.kt

@ -0,0 +1,77 @@
package example.imageviewer.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal actual fun BoxScope.EditMemoryDialog(
previousName: String,
previousDescription: String,
save: (name: String, description: String) -> Unit
) {
var name by remember { mutableStateOf(previousName) }
var description by remember { mutableStateOf(previousDescription) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
save(name, description)
}
) {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(30.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
TextField(
value = description,
onValueChange = { description = it },
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.White,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
}
}
}

28
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt

@ -1,6 +1,7 @@
package example.imageviewer.view
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitInteropView
import example.imageviewer.model.GpsPosition
@ -11,26 +12,29 @@ import platform.MapKit.MKPointAnnotation
@Composable
internal actual fun LocationVisualizer(modifier: Modifier, gps: GpsPosition, title: String) {
val location = CLLocationCoordinate2DMake(gps.latitude, gps.longitude)
val annotation = remember {
MKPointAnnotation(
location,
title = null,
subtitle = null
)
}
val mkMapView = remember { MKMapView().apply { addAnnotation(annotation) } }
annotation.setTitle(title)
UIKitInteropView(
modifier = modifier,
factory = {
val mkMapView = MKMapView()
val cityAmsterdam = CLLocationCoordinate2DMake(gps.latitude, gps.longitude)
mkMapView
},
update = {
mkMapView.setRegion(
MKCoordinateRegionMakeWithDistance(
centerCoordinate = cityAmsterdam,
centerCoordinate = location,
10_000.0, 10_000.0
),
animated = false
)
mkMapView.addAnnotation(
MKPointAnnotation(
cityAmsterdam,
title = title,
subtitle = null
)
)
mkMapView
},
}
)
}

Loading…
Cancel
Save