feat(common-ui): add save image to gallery (#26)

This commit is contained in:
Diego Beraldin 2023-09-18 19:31:39 +02:00 committed by GitHub
parent 008838b5ca
commit 88b54c2263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 215 additions and 4 deletions

View File

@ -0,0 +1,28 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun ProgressHud(
overlayColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
color: Color = MaterialTheme.colorScheme.secondary,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(overlayColor),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = color,
)
}
}

View File

@ -103,6 +103,7 @@ val commonUiModule = module {
ZoomableImageViewModel( ZoomableImageViewModel(
mvi = DefaultMviModel(ZoomableImageMviModel.UiState()), mvi = DefaultMviModel(ZoomableImageMviModel.UiState()),
shareHelper = get(), shareHelper = get(),
galleryHelper = get(),
) )
} }
} }

View File

@ -6,6 +6,7 @@ interface ZoomableImageMviModel :
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> { MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> {
sealed interface Intent { sealed interface Intent {
data class Share(val url: String) : Intent data class Share(val url: String) : Intent
data class SaveToGallery(val url: String) : Intent
} }
data class UiState(val loading: Boolean = false) data class UiState(val loading: Boolean = false)

View File

@ -2,10 +2,13 @@ package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -13,6 +16,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -20,7 +25,9 @@ import androidx.compose.ui.graphics.Color
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import com.github.diegoberaldin.racconforlemmy.core.utils.onClick import com.github.diegoberaldin.racconforlemmy.core.utils.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.ProgressHud
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.ZoomableImage import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.ZoomableImage
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCoordinator import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getZoomableImageViewModel import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getZoomableImageViewModel
@ -32,9 +39,9 @@ class ZoomableImageScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
override fun Content() { override fun Content() {
val model = rememberScreenModel { getZoomableImageViewModel() } val model = rememberScreenModel { getZoomableImageViewModel() }
model.bindToLifecycle(key) model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
val navigator = remember { getNavigationCoordinator().getRootNavigator() } val navigator = remember { getNavigationCoordinator().getRootNavigator() }
Scaffold( Scaffold(
@ -52,6 +59,15 @@ class ZoomableImageScreen(
) )
}, },
actions = { actions = {
Icon(
modifier = Modifier.onClick {
model.reduce(ZoomableImageMviModel.Intent.SaveToGallery(url))
},
imageVector = Icons.Default.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.width(Spacing.s))
Icon( Icon(
modifier = Modifier.onClick { modifier = Modifier.onClick {
model.reduce(ZoomableImageMviModel.Intent.Share(url)) model.reduce(ZoomableImageMviModel.Intent.Share(url))
@ -78,5 +94,9 @@ class ZoomableImageScreen(
} }
) )
if (uiState.loading) {
ProgressHud()
}
} }
} }

View File

@ -1,13 +1,20 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.racconforlemmy.core.utils.DateTime
import com.github.diegoberaldin.racconforlemmy.core.utils.GalleryHelper
import com.github.diegoberaldin.racconforlemmy.core.utils.ShareHelper import com.github.diegoberaldin.racconforlemmy.core.utils.ShareHelper
import com.github.diegoberaldin.racconforlemmy.core.utils.download
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class ZoomableImageViewModel( class ZoomableImageViewModel(
private val mvi: DefaultMviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect>, private val mvi: DefaultMviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect>,
private val shareHelper: ShareHelper, private val shareHelper: ShareHelper,
private val galleryHelper: GalleryHelper,
) : ScreenModel, ) : ScreenModel,
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> by mvi { MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> by mvi {
@ -16,6 +23,26 @@ class ZoomableImageViewModel(
is ZoomableImageMviModel.Intent.Share -> { is ZoomableImageMviModel.Intent.Share -> {
shareHelper.share(intent.url, "image/*") shareHelper.share(intent.url, "image/*")
} }
is ZoomableImageMviModel.Intent.SaveToGallery -> downloadAndSave(intent.url)
}
}
private fun downloadAndSave(url: String) {
mvi.scope?.launch(Dispatchers.IO) {
mvi.updateState { it.copy(loading = true) }
try {
val bytes = galleryHelper.download(url)
val extension = url.let { s ->
val idx = s.lastIndexOf(".").takeIf { it >= 0 } ?: s.length
s.substring(idx).takeIf { it.isNotEmpty() } ?: ".jpeg"
}
galleryHelper.saveToGallery(bytes, "${DateTime.epochMillis()}.$extension")
} catch (e: Throwable) {
e.printStackTrace()
} finally {
mvi.updateState { it.copy(loading = false) }
}
} }
} }
} }

View File

@ -40,6 +40,7 @@ kotlin {
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.ktor.cio)
implementation(projects.resources) implementation(projects.resources)
} }
} }

View File

@ -6,6 +6,8 @@ import java.time.format.DateTimeFormatter
import java.util.GregorianCalendar import java.util.GregorianCalendar
actual object DateTime { actual object DateTime {
actual fun epochMillis(): Long = System.currentTimeMillis()
actual fun getFormattedDate( actual fun getFormattedDate(
iso8601Timestamp: String, iso8601Timestamp: String,
format: String, format: String,

View File

@ -0,0 +1,49 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import org.koin.dsl.module
class DefaultGalleryHelper(
private val context: Context,
) : GalleryHelper {
override fun saveToGallery(bytes: ByteArray, name: String) {
val resolver = context.applicationContext.contentResolver
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val details = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(collection, details)
if (uri != null) {
resolver.openFileDescriptor(uri, "w", null).use { pfd ->
ParcelFileDescriptor.AutoCloseOutputStream(pfd).use {
it.write(bytes)
}
}
details.clear()
details.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, details, null, null)
}
}
}
actual val galleryHelperModule = module {
single<GalleryHelper> {
DefaultGalleryHelper(
context = get()
)
}
}

View File

@ -1,6 +1,8 @@
package com.github.diegoberaldin.racconforlemmy.core.utils package com.github.diegoberaldin.racconforlemmy.core.utils
expect object DateTime { expect object DateTime {
fun epochMillis(): Long
fun getFormattedDate( fun getFormattedDate(
iso8601Timestamp: String, iso8601Timestamp: String,
format: String, format: String,

View File

@ -10,6 +10,17 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import com.github.diegoberaldin.raccoonforlemmy.resources.MR import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.prepareGet
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isEmpty
import io.ktor.utils.io.core.readBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun Modifier.onClick(onClick: () -> Unit): Modifier = composed { fun Modifier.onClick(onClick: () -> Unit): Modifier = composed {
@ -47,3 +58,20 @@ fun Int.getPrettyNumber(
else -> this.toString() else -> this.toString()
} }
} }
suspend fun GalleryHelper.download(url: String): ByteArray = withContext(Dispatchers.IO) {
val client = HttpClient(CIO)
client.prepareGet(url).execute { httpResponse ->
val channel: ByteReadChannel = httpResponse.body()
var result = byteArrayOf()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(4096)
while (!packet.isEmpty) {
val bytes = packet.readBytes()
result += bytes
println("Received ${result.size} bytes / ${httpResponse.contentLength()}")
}
}
result
}
}

View File

@ -0,0 +1,10 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import org.koin.core.module.Module
interface GalleryHelper {
fun saveToGallery(bytes: ByteArray, name: String)
}
expect val galleryHelperModule: Module

View File

@ -15,9 +15,14 @@ import platform.Foundation.NSLocale
import platform.Foundation.NSTimeZone import platform.Foundation.NSTimeZone
import platform.Foundation.autoupdatingCurrentLocale import platform.Foundation.autoupdatingCurrentLocale
import platform.Foundation.localTimeZone import platform.Foundation.localTimeZone
import platform.Foundation.timeIntervalSince1970
actual object DateTime { actual object DateTime {
actual fun epochMillis(): Long {
return (NSDate().timeIntervalSince1970 * 1000).toLong()
}
actual fun getFormattedDate( actual fun getFormattedDate(
iso8601Timestamp: String, iso8601Timestamp: String,
format: String, format: String,
@ -48,7 +53,7 @@ actual object DateTime {
.or(NSCalendarUnitDay).or(NSCalendarUnitMonth).or(NSCalendarUnitYear), .or(NSCalendarUnitDay).or(NSCalendarUnitMonth).or(NSCalendarUnitYear),
fromDate = date, fromDate = date,
toDate = now, toDate = now,
options = 0, options = 0u,
) )
return when { return when {
delta.year >= 1 -> buildString { delta.year >= 1 -> buildString {

View File

@ -0,0 +1,33 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import kotlinx.cinterop.allocArrayOf
import kotlinx.cinterop.memScoped
import org.koin.dsl.module
import platform.Foundation.NSData
import platform.Foundation.create
import platform.UIKit.UIImage
import platform.UIKit.UIImageWriteToSavedPhotosAlbum
typealias ImageBytes = NSData
fun ByteArray.toImageBytes(): ImageBytes = memScoped {
return NSData.create(
bytes = allocArrayOf(this@toImageBytes),
length = this@toImageBytes.size.toULong()
)
}
class DefaultGalleryHelper : GalleryHelper {
override fun saveToGallery(bytes: ByteArray, name: String) {
val image = UIImage(bytes.toImageBytes())
UIImageWriteToSavedPhotosAlbum(image, null, null, null)
}
}
actual val galleryHelperModule = module {
single<GalleryHelper> {
DefaultGalleryHelper()
}
}

View File

@ -4,7 +4,7 @@ import org.koin.dsl.module
import platform.UIKit.UIActivityViewController import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication import platform.UIKit.UIApplication
class DefaultShareHelper() : ShareHelper { class DefaultShareHelper : ShareHelper {
override fun share(url: String, mimeType: String) { override fun share(url: String, mimeType: String) {
val shareActivity = UIActivityViewController(listOf(url), null) val shareActivity = UIActivityViewController(listOf(url), null)
val rvc = UIApplication.sharedApplication().keyWindow?.rootViewController val rvc = UIApplication.sharedApplication().keyWindow?.rootViewController

View File

@ -1,5 +1,6 @@
package com.github.diegoberaldin.raccoonforlemmy.di package com.github.diegoberaldin.raccoonforlemmy.di
import com.github.diegoberaldin.racconforlemmy.core.utils.galleryHelperModule
import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule
import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
@ -28,6 +29,7 @@ val sharedHelperModule = module {
hapticFeedbackModule, hapticFeedbackModule,
localizationModule, localizationModule,
shareHelperModule, shareHelperModule,
galleryHelperModule,
homeTabModule, homeTabModule,
inboxTabModule, inboxTabModule,
profileTabModule, profileTabModule,

View File

@ -1,6 +1,7 @@
package com.github.diegoberaldin.raccoonforlemmy.di package com.github.diegoberaldin.raccoonforlemmy.di
import com.github.diegoberaldin.racconforlemmy.core.utils.AppInfo import com.github.diegoberaldin.racconforlemmy.core.utils.AppInfo
import com.github.diegoberaldin.racconforlemmy.core.utils.galleryHelperModule
import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule
import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
@ -31,6 +32,7 @@ fun initKoin() {
hapticFeedbackModule, hapticFeedbackModule,
localizationModule, localizationModule,
shareHelperModule, shareHelperModule,
galleryHelperModule,
homeTabModule, homeTabModule,
inboxTabModule, inboxTabModule,
profileTabModule, profileTabModule,