mirror of
https://github.com/LiveFastEatTrashRaccoon/RaccoonForLemmy.git
synced 2025-02-09 06:28:40 +01:00
feat(common-ui): add save image to gallery (#26)
This commit is contained in:
parent
008838b5ca
commit
88b54c2263
@ -20,4 +20,4 @@
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
</manifest>
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -103,6 +103,7 @@ val commonUiModule = module {
|
||||
ZoomableImageViewModel(
|
||||
mvi = DefaultMviModel(ZoomableImageMviModel.UiState()),
|
||||
shareHelper = get(),
|
||||
galleryHelper = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ interface ZoomableImageMviModel :
|
||||
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> {
|
||||
sealed interface Intent {
|
||||
data class Share(val url: String) : Intent
|
||||
data class SaveToGallery(val url: String) : Intent
|
||||
}
|
||||
|
||||
data class UiState(val loading: Boolean = false)
|
||||
|
@ -2,10 +2,13 @@ package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@ -13,6 +16,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.screen.Screen
|
||||
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.commonui.components.ProgressHud
|
||||
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.getZoomableImageViewModel
|
||||
@ -32,9 +39,9 @@ class ZoomableImageScreen(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
|
||||
val model = rememberScreenModel { getZoomableImageViewModel() }
|
||||
model.bindToLifecycle(key)
|
||||
val uiState by model.uiState.collectAsState()
|
||||
val navigator = remember { getNavigationCoordinator().getRootNavigator() }
|
||||
|
||||
Scaffold(
|
||||
@ -52,6 +59,15 @@ class ZoomableImageScreen(
|
||||
)
|
||||
},
|
||||
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(
|
||||
modifier = Modifier.onClick {
|
||||
model.reduce(ZoomableImageMviModel.Intent.Share(url))
|
||||
@ -78,5 +94,9 @@ class ZoomableImageScreen(
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState.loading) {
|
||||
ProgressHud()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +1,20 @@
|
||||
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
|
||||
|
||||
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.download
|
||||
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
|
||||
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ZoomableImageViewModel(
|
||||
private val mvi: DefaultMviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect>,
|
||||
private val shareHelper: ShareHelper,
|
||||
private val galleryHelper: GalleryHelper,
|
||||
) : ScreenModel,
|
||||
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> by mvi {
|
||||
|
||||
@ -16,6 +23,26 @@ class ZoomableImageViewModel(
|
||||
is ZoomableImageMviModel.Intent.Share -> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -40,6 +40,7 @@ kotlin {
|
||||
implementation(compose.components.resources)
|
||||
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.ktor.cio)
|
||||
implementation(projects.resources)
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import java.time.format.DateTimeFormatter
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
actual object DateTime {
|
||||
actual fun epochMillis(): Long = System.currentTimeMillis()
|
||||
|
||||
actual fun getFormattedDate(
|
||||
iso8601Timestamp: String,
|
||||
format: String,
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
package com.github.diegoberaldin.racconforlemmy.core.utils
|
||||
|
||||
expect object DateTime {
|
||||
fun epochMillis(): Long
|
||||
|
||||
fun getFormattedDate(
|
||||
iso8601Timestamp: String,
|
||||
format: String,
|
||||
|
@ -10,6 +10,17 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
|
||||
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
|
||||
|
||||
fun Modifier.onClick(onClick: () -> Unit): Modifier = composed {
|
||||
@ -47,3 +58,20 @@ fun Int.getPrettyNumber(
|
||||
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
|
||||
}
|
||||
}
|
@ -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
|
@ -15,9 +15,14 @@ import platform.Foundation.NSLocale
|
||||
import platform.Foundation.NSTimeZone
|
||||
import platform.Foundation.autoupdatingCurrentLocale
|
||||
import platform.Foundation.localTimeZone
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
|
||||
actual object DateTime {
|
||||
|
||||
actual fun epochMillis(): Long {
|
||||
return (NSDate().timeIntervalSince1970 * 1000).toLong()
|
||||
}
|
||||
|
||||
actual fun getFormattedDate(
|
||||
iso8601Timestamp: String,
|
||||
format: String,
|
||||
@ -48,7 +53,7 @@ actual object DateTime {
|
||||
.or(NSCalendarUnitDay).or(NSCalendarUnitMonth).or(NSCalendarUnitYear),
|
||||
fromDate = date,
|
||||
toDate = now,
|
||||
options = 0,
|
||||
options = 0u,
|
||||
)
|
||||
return when {
|
||||
delta.year >= 1 -> buildString {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import org.koin.dsl.module
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIApplication
|
||||
|
||||
class DefaultShareHelper() : ShareHelper {
|
||||
class DefaultShareHelper : ShareHelper {
|
||||
override fun share(url: String, mimeType: String) {
|
||||
val shareActivity = UIActivityViewController(listOf(url), null)
|
||||
val rvc = UIApplication.sharedApplication().keyWindow?.rootViewController
|
||||
|
@ -1,5 +1,6 @@
|
||||
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.shareHelperModule
|
||||
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
|
||||
@ -28,6 +29,7 @@ val sharedHelperModule = module {
|
||||
hapticFeedbackModule,
|
||||
localizationModule,
|
||||
shareHelperModule,
|
||||
galleryHelperModule,
|
||||
homeTabModule,
|
||||
inboxTabModule,
|
||||
profileTabModule,
|
||||
|
@ -1,6 +1,7 @@
|
||||
package com.github.diegoberaldin.raccoonforlemmy.di
|
||||
|
||||
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.shareHelperModule
|
||||
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
|
||||
@ -31,6 +32,7 @@ fun initKoin() {
|
||||
hapticFeedbackModule,
|
||||
localizationModule,
|
||||
shareHelperModule,
|
||||
galleryHelperModule,
|
||||
homeTabModule,
|
||||
inboxTabModule,
|
||||
profileTabModule,
|
||||
|
Loading…
x
Reference in New Issue
Block a user