ImageViewer delete, edit and share memories (#2957)

This commit is contained in:
dima.avdeev
2023-04-03 16:21:20 +03:00
committed by GitHub
parent b87868d75e
commit 2cea802cec
28 changed files with 683 additions and 100 deletions

View File

@@ -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")

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -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 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()
}
@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 {

View File

@@ -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,
)
)
}
}
}

View File

@@ -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))
}
}
}
}
}

View File

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

View File

@@ -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")
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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]!!
}
}

View File

@@ -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

View File

@@ -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 {
}
) {
}
}

View File

@@ -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
}

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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,
)
)
}
}
}

View File

@@ -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
},
}
)
}