diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt index 2e32ebe23..a63e5a185 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt @@ -72,6 +72,8 @@ import io.github.droidkaigi.confsched.model.AboutItem import io.github.droidkaigi.confsched.model.Lang.JAPANESE import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.model.defaultLang +import io.github.droidkaigi.confsched.profilecard.cropImageScreenRoute +import io.github.droidkaigi.confsched.profilecard.cropImageScreens import io.github.droidkaigi.confsched.profilecard.navigateProfileCardScreen import io.github.droidkaigi.confsched.profilecard.profileCardScreen import io.github.droidkaigi.confsched.profilecard.profileCardScreenRoute @@ -189,6 +191,11 @@ private fun KaigiNavHost( onTimetableItemClick = navController::navigateToTimetableItemDetailScreen, contentPadding = PaddingValues(), ) + + cropImageScreens( + onNavigationIconClick = navController::popBackStack, + onBackWithConfirm = navController::popBackStack, + ) } } } @@ -267,6 +274,7 @@ private fun NavGraphBuilder.mainScreen( profileCardScreen( contentPadding = contentPadding, onClickShareProfileCard = externalNavController::onShareProfileCardClick, + onNavigateToCropImage = { navController.navigate(cropImageScreenRoute) }, ) }, ) diff --git a/app-ios-shared/src/commonMain/kotlin/io/github/droidkaigi/confsched/shared/IosComposeKaigiApp.kt b/app-ios-shared/src/commonMain/kotlin/io/github/droidkaigi/confsched/shared/IosComposeKaigiApp.kt index 7fcbe7e53..0c12f5628 100644 --- a/app-ios-shared/src/commonMain/kotlin/io/github/droidkaigi/confsched/shared/IosComposeKaigiApp.kt +++ b/app-ios-shared/src/commonMain/kotlin/io/github/droidkaigi/confsched/shared/IosComposeKaigiApp.kt @@ -22,8 +22,8 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import co.touchlab.kermit.Logger -import conference_app_2024.app_ios_shared.generated.resources.permission_required import conference_app_2024.app_ios_shared.generated.resources.open_settings +import conference_app_2024.app_ios_shared.generated.resources.permission_required import io.github.droidkaigi.confsched.about.aboutScreen import io.github.droidkaigi.confsched.about.aboutScreenRoute import io.github.droidkaigi.confsched.about.navigateAboutScreen @@ -64,6 +64,8 @@ import io.github.droidkaigi.confsched.model.SettingsRepository import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.model.compositionlocal.LocalRepositories import io.github.droidkaigi.confsched.model.defaultLang +import io.github.droidkaigi.confsched.profilecard.cropImageScreenRoute +import io.github.droidkaigi.confsched.profilecard.cropImageScreens import io.github.droidkaigi.confsched.profilecard.navigateProfileCardScreen import io.github.droidkaigi.confsched.profilecard.profileCardScreen import io.github.droidkaigi.confsched.profilecard.profileCardScreenRoute @@ -251,6 +253,11 @@ private fun KaigiNavHost( onTimetableItemClick = navController::navigateToTimetableItemDetailScreen, contentPadding = PaddingValues(), ) + + cropImageScreens( + onNavigationIconClick = navController::popBackStack, + onBackWithConfirm = navController::popBackStack, + ) } } @@ -327,6 +334,7 @@ private fun NavGraphBuilder.mainScreen( profileCardScreen( contentPadding = contentPadding, onClickShareProfileCard = externalNavController::onShareProfileCardClick, + onNavigateToCropImage = { navController.navigate(cropImageScreenRoute) }, ) }, ) diff --git a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/DefaultProfileCardRepository.kt b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/DefaultProfileCardRepository.kt index 42ee4c579..f30d5cbcc 100644 --- a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/DefaultProfileCardRepository.kt +++ b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/DefaultProfileCardRepository.kt @@ -6,7 +6,9 @@ import androidx.compose.runtime.remember import io.github.droidkaigi.confsched.compose.safeCollectAsRetainedState import io.github.droidkaigi.confsched.model.ProfileCard import io.github.droidkaigi.confsched.model.ProfileCardRepository +import io.github.droidkaigi.confsched.model.ProfileImage import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.ExperimentalResourceApi @@ -18,6 +20,9 @@ public class DefaultProfileCardRepository( private val profileCardDataStore: ProfileCardDataStore, private val ioDispatcher: CoroutineDispatcher, ) : ProfileCardRepository { + private val profileImageInEditStateFlow = MutableStateFlow(null) + private val profileImageCandidateStateFlow = MutableStateFlow(null) + @Composable override fun profileCard(): ProfileCard { val profileCard by remember { @@ -32,7 +37,10 @@ public class DefaultProfileCardRepository( } @OptIn(ExperimentalResourceApi::class) - override suspend fun loadQrCodeImageByteArray(link: String, centerLogoRes: DrawableResource): ByteArray { + override suspend fun loadQrCodeImageByteArray( + link: String, + centerLogoRes: DrawableResource, + ): ByteArray { return withContext(ioDispatcher) { val logoImage = getDrawableResourceBytes( environment = getSystemResourceEnvironment(), @@ -44,4 +52,32 @@ public class DefaultProfileCardRepository( .renderToBytes() } } + + @Composable + override fun profileImageInEdit(): ProfileImage? { + val profileImage by profileImageInEditStateFlow.safeCollectAsRetainedState() + return profileImage + } + + override fun setProfileImageInEdit(profileImage: ProfileImage) { + profileImageInEditStateFlow.value = profileImage + } + + override fun clearProfileImageInEdit() { + profileImageInEditStateFlow.value = null + } + + @Composable + override fun profileImageCandidate(): ProfileImage? { + val profileImageCandidate by profileImageCandidateStateFlow.safeCollectAsRetainedState() + return profileImageCandidate + } + + override fun setProfileImageCandidate(profileImage: ProfileImage) { + profileImageCandidateStateFlow.value = profileImage + } + + override fun clearProfileImageCache() { + profileImageCandidateStateFlow.value = null + } } diff --git a/core/model/src/androidMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt b/core/model/src/androidMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt new file mode 100644 index 000000000..b34aa69c0 --- /dev/null +++ b/core/model/src/androidMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt @@ -0,0 +1,25 @@ +package io.github.droidkaigi.confsched.model + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import java.io.ByteArrayOutputStream + +actual val ByteArray.imageSize: IntSize + get() = this.asBitmap().let { + IntSize(width = it.width, height = it.height) + } + +actual fun ByteArray.crop(rect: IntRect): ByteArray { + val cropped = Bitmap.createBitmap(this.asBitmap(), rect.left, rect.top, rect.width, rect.height) + + return ByteArrayOutputStream(cropped.byteCount).use { stream -> + cropped.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.toByteArray() + } +} + +private fun ByteArray.asBitmap(): Bitmap { + return BitmapFactory.decodeByteArray(this, 0, size) +} diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardRepository.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardRepository.kt index 3ba6ca212..4ba525952 100644 --- a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardRepository.kt +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardRepository.kt @@ -9,6 +9,16 @@ interface ProfileCardRepository { fun profileCard(): ProfileCard suspend fun save(profileCard: ProfileCard.Exists) suspend fun loadQrCodeImageByteArray(link: String, centerLogoRes: DrawableResource): ByteArray + + @Composable + fun profileImageInEdit(): ProfileImage? + fun setProfileImageInEdit(profileImage: ProfileImage) + fun clearProfileImageInEdit() + + @Composable + fun profileImageCandidate(): ProfileImage? + fun setProfileImageCandidate(profileImage: ProfileImage) + fun clearProfileImageCache() } @Composable diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt new file mode 100644 index 000000000..dd42ecb58 --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt @@ -0,0 +1,33 @@ +package io.github.droidkaigi.confsched.model + +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize + +data class ProfileImage( + val bytes: ByteArray, +) { + val size: IntSize + get() = bytes.imageSize + + fun crop(rect: IntRect): ProfileImage { + return copy( + bytes = bytes.crop(rect), + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ProfileImage + + return bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + return bytes.contentHashCode() + } +} + +expect val ByteArray.imageSize: IntSize +expect fun ByteArray.crop(rect: IntRect): ByteArray diff --git a/core/model/src/iosMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt b/core/model/src/iosMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt new file mode 100644 index 000000000..d026d46c2 --- /dev/null +++ b/core/model/src/iosMain/kotlin/io/github/droidkaigi/confsched/model/ProfileImage.kt @@ -0,0 +1,30 @@ +package io.github.droidkaigi.confsched.model + +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image + +actual val ByteArray.imageSize: IntSize + get() = Bitmap.makeFromImage( + Image.makeFromEncoded(this), + ).let { + IntSize(width = it.width, height = it.height) + } + +actual fun ByteArray.crop(rect: IntRect): ByteArray { + val image = Image.makeFromEncoded(this) + + val bitmap = Bitmap() + val imageInfo = image.imageInfo.withWidthHeight( + width = rect.width, + height = rect.height, + ) + bitmap.allocPixels(imageInfo) + image.readPixels(dst = bitmap, srcX = rect.left, srcY = rect.top) + + val data = Image.makeFromBitmap(bitmap) + .encodeToData(format = EncodedImageFormat.PNG, quality = 100) + return requireNotNull(data).bytes +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/CropImageScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/CropImageScreenRobot.kt new file mode 100644 index 000000000..0f81bb35e --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/CropImageScreenRobot.kt @@ -0,0 +1,31 @@ +package io.github.droidkaigi.confsched.testing.robot + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.profilecard.CropImageScreen +import io.github.droidkaigi.confsched.profilecard.CropImageScreenTestTag +import javax.inject.Inject + +class CropImageScreenRobot @Inject constructor( + screenRobot: DefaultScreenRobot, +) : ScreenRobot by screenRobot { + + fun setupScreenContent() { + robotTestRule.setContent { + KaigiTheme { + CropImageScreen( + onNavigationIconClick = {}, + onBackWithConfirm = {}, + ) + } + } + waitUntilIdle() + } + + fun checkScreenDisplayed() { + composeTestRule + .onNode(hasTestTag(CropImageScreenTestTag)) + .assertIsDisplayed() + } +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt index 2a51b61d8..215330d9c 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt @@ -41,6 +41,7 @@ class ProfileCardScreenRobot @Inject constructor( KaigiTheme { ProfileCardScreen( onClickShareProfileCard = { _, _ -> }, + onNavigateToCropImage = {}, ) } } diff --git a/feature/profilecard/src/androidMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt b/feature/profilecard/src/androidMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt new file mode 100644 index 000000000..18a77ae48 --- /dev/null +++ b/feature/profilecard/src/androidMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt @@ -0,0 +1,23 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.runtime.Composable +import com.preat.peekaboo.image.picker.ImagePickerLauncher +import com.preat.peekaboo.image.picker.toImageBitmap +import kotlinx.coroutines.CoroutineScope + +@Composable +internal actual fun rememberCroppingImagePickerLauncher( + onCropImage: (ByteArray) -> Unit, + onSelectedImage: (ByteArray) -> Unit, + scope: CoroutineScope, +): ImagePickerLauncher { + return rememberSingleImagePickerLauncher(scope) { + val imageBitmap = it.toImageBitmap() + + if (imageBitmap.height != imageBitmap.width) { + onCropImage(it) + } else { + onSelectedImage(it) + } + } +} diff --git a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenTest.kt b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenTest.kt new file mode 100644 index 000000000..885a78360 --- /dev/null +++ b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenTest.kt @@ -0,0 +1,55 @@ +package io.github.droidkaigi.confsched.profilecard + +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.droidkaigi.confsched.testing.DescribedBehavior +import io.github.droidkaigi.confsched.testing.describeBehaviors +import io.github.droidkaigi.confsched.testing.execute +import io.github.droidkaigi.confsched.testing.robot.CropImageScreenRobot +import io.github.droidkaigi.confsched.testing.robot.runRobot +import io.github.droidkaigi.confsched.testing.rules.RobotTestRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import javax.inject.Inject + +@RunWith(ParameterizedRobolectricTestRunner::class) +@HiltAndroidTest +class CropImageScreenTest( + private val testCase: DescribedBehavior, +) { + + @get:Rule + @BindValue + val robotTestRule: RobotTestRule = RobotTestRule(testInstance = this) + + @Inject + lateinit var cropImageScreenRobot: CropImageScreenRobot + + @Test + fun runTest() { + runRobot(cropImageScreenRobot) { + testCase.execute(cropImageScreenRobot) + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun behaviors(): List> { + return describeBehaviors(name = "CropImageScreen") { + describe("when launch") { + doIt { + setupScreenContent() + } + itShould("show screen") { + captureScreenWithChecks { + checkScreenDisplayed() + } + } + } + } + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreen.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreen.kt new file mode 100644 index 000000000..2d0962a07 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreen.kt @@ -0,0 +1,352 @@ +package io.github.droidkaigi.confsched.profilecard + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons.AutoMirrored.Filled +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import androidx.compose.ui.unit.toSize +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.preat.peekaboo.image.picker.toImageBitmap +import io.github.droidkaigi.confsched.compose.EventFlow +import io.github.droidkaigi.confsched.compose.rememberEventFlow +import io.github.droidkaigi.confsched.model.ProfileImage +import io.github.droidkaigi.confsched.profilecard.component.ImageCropAreaSelector +import kotlin.math.max + +const val cropImageScreenRoute = "cropImage" + +const val CropImageScreenTestTag = "CropImageScreenTestTag" + +fun NavGraphBuilder.cropImageScreens( + onNavigationIconClick: () -> Unit, + onBackWithConfirm: () -> Unit, +) { + composable( + cropImageScreenRoute, + ) { + CropImageScreen( + onNavigationIconClick = dropUnlessResumed(block = onNavigationIconClick), + onBackWithConfirm = onBackWithConfirm, + ) + } +} + +internal sealed interface CropImageScreenState { + data object Init : CropImageScreenState + + data class Select( + val profileImage: ProfileImage, + val isProcessing: Boolean, + ) : CropImageScreenState + + data class Confirm( + val profileImage: ProfileImage, + val shouldBack: Boolean, + ) : CropImageScreenState +} + +@Composable +fun CropImageScreen( + onNavigationIconClick: () -> Unit, + onBackWithConfirm: () -> Unit, + modifier: Modifier = Modifier, +) { + CropImageScreen( + onNavigationIconClick = onNavigationIconClick, + onBackWithConfirm = onBackWithConfirm, + modifier = modifier, + eventFlow = rememberEventFlow(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CropImageScreen( + onNavigationIconClick: () -> Unit, + onBackWithConfirm: () -> Unit, + modifier: Modifier = Modifier, + eventFlow: EventFlow = rememberEventFlow(), + uiState: CropImageScreenState = cropImageScreenPresenter(eventFlow), +) { + LaunchedEffect(uiState is CropImageScreenState.Confirm && uiState.shouldBack) { + if (uiState is CropImageScreenState.Confirm && uiState.shouldBack) { + onBackWithConfirm() + } + } + + Scaffold( + modifier = modifier + .testTag(CropImageScreenTestTag) + .fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text("Crop Image") + }, + navigationIcon = { + IconButton( + onClick = onNavigationIconClick, + ) { + Icon( + imageVector = Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { contentPadding -> + when (uiState) { + is CropImageScreenState.Init -> Unit + is CropImageScreenState.Select -> { + Box( + modifier = Modifier.padding(contentPadding), + ) { + SelectScreen( + profileImage = uiState.profileImage, + isCropButtonEnabled = !uiState.isProcessing, + onCrop = { cropRect -> + eventFlow.tryEmit( + CropImageScreenEvent.Crop( + rect = cropRect, + ), + ) + }, + ) + + if (uiState.isProcessing) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + + is CropImageScreenState.Confirm -> { + ConfirmScreen( + profileImage = uiState.profileImage, + onConfirm = { + eventFlow.tryEmit(CropImageScreenEvent.Confirm) + }, + onCancel = { + eventFlow.tryEmit(CropImageScreenEvent.Cancel) + }, + modifier = Modifier.padding(contentPadding), + ) + } + } + } +} + +@Composable +private fun SelectScreen( + profileImage: ProfileImage, + isCropButtonEnabled: Boolean, + onCrop: (cropRect: IntRect) -> Unit, + modifier: Modifier = Modifier, +) { + val cropRectRatio = 0.6f + var transformCalculator: TransformCalculator? by remember { mutableStateOf(null) } + var scale: Float by remember { mutableFloatStateOf(1f) } + var offset: Offset by remember { mutableStateOf(Offset.Zero) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + val density = LocalDensity.current + val calculator = remember(profileImage, maxWidth, maxHeight, density) { + val imageSize = profileImage.size.toSize() + val layoutSize = with(density) { + Size( + width = maxWidth.toPx(), + height = maxHeight.toPx(), + ) + } + + TransformCalculator( + cropRectRatio = cropRectRatio, + layoutSize = layoutSize, + imageSize = imageSize, + ).also { calculator -> + scale = max(calculator.minScale, 1f) + transformCalculator = calculator + } + } + + ImageCropAreaSelector( + profileImage = profileImage, + transformCalculator = calculator, + scale = { scale }, + offset = { offset }, + onTransform = { newScale, newOffset -> + scale = newScale + offset = newOffset + }, + ) + } + + Button( + onClick = { + val calculator = requireNotNull(transformCalculator) + val cropRect = calculator.calculateCropRectInImage( + scale = scale, + offset = offset, + ) + onCrop(cropRect.roundToIntRect()) + }, + enabled = isCropButtonEnabled, + ) { + Text("Crop") + } + } +} + +internal class TransformCalculator( + cropRectRatio: Float, + layoutSize: Size, + private val imageSize: Size, +) { + private val imageLayoutRatio: Float = + if (imageSize.width / imageSize.height >= layoutSize.width / layoutSize.height) { + layoutSize.width / imageSize.width + } else { + layoutSize.height / imageSize.height + } + + val cropRectLength: Float = if (layoutSize.width <= layoutSize.height) { + layoutSize.width * cropRectRatio + } else { + layoutSize.height * cropRectRatio + } + + val minScale: Float = if (imageSize.width <= imageSize.height) { + cropRectLength / (imageSize.width * imageLayoutRatio) + } else { + cropRectLength / (imageSize.height * imageLayoutRatio) + } + + fun calculateNext( + oldScale: Float, + oldOffset: Offset, + zoom: Float, + pan: Offset, + ): Pair { + val tempOffset = oldOffset - pan / oldScale + val newScale = (oldScale * zoom).coerceAtLeast(minScale) + + val minOffsetX = + (cropRectLength / newScale - imageSize.width * imageLayoutRatio) / 2 + val minOffsetY = + (cropRectLength / newScale - imageSize.height * imageLayoutRatio) / 2 + + val newOffset = Offset( + x = tempOffset.x.coerceIn(minOffsetX, -minOffsetX), + y = tempOffset.y.coerceIn(minOffsetY, -minOffsetY), + ) + + return newScale to newOffset + } + + fun calculateCropRectInImage( + scale: Float, + offset: Offset, + ): Rect { + val cropRect = Rect( + offset = offset * scale - Offset(cropRectLength, cropRectLength) / 2f, + size = Size(cropRectLength, cropRectLength), + ) + return Rect( + offset = cropRect.topLeft / (scale * imageLayoutRatio) + imageSize.toRect().center, + size = cropRect.size / (scale * imageLayoutRatio), + ) + } +} + +@Composable +private fun ConfirmScreen( + profileImage: ProfileImage, + onConfirm: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.fillMaxSize(), + bitmap = profileImage.bytes.toImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + Button( + onClick = onConfirm, + ) { + Text("Confirm") + } + Button( + onClick = onCancel, + ) { + Text("Cancel") + } + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenPresenter.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenPresenter.kt new file mode 100644 index 000000000..2e9a35942 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/CropImageScreenPresenter.kt @@ -0,0 +1,83 @@ +package io.github.droidkaigi.confsched.profilecard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.IntRect +import io.github.droidkaigi.confsched.compose.SafeLaunchedEffect +import io.github.droidkaigi.confsched.droidkaigiui.providePresenterDefaults +import io.github.droidkaigi.confsched.model.ProfileCardRepository +import io.github.droidkaigi.confsched.model.ProfileImage +import io.github.droidkaigi.confsched.model.localProfileCardRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +internal sealed interface CropImageScreenEvent { + data class Crop(val rect: IntRect) : CropImageScreenEvent + data object Confirm : CropImageScreenEvent + data object Cancel : CropImageScreenEvent +} + +@Composable +internal fun cropImageScreenPresenter( + events: Flow, + repository: ProfileCardRepository = localProfileCardRepository(), +): CropImageScreenState = providePresenterDefaults { _ -> + val profileImageCandidate: ProfileImage? by rememberUpdatedState(repository.profileImageCandidate()) + var croppedProfileImage: ProfileImage? by remember { mutableStateOf(null) } + var isProcessing: Boolean by remember { mutableStateOf(false) } + var shouldBack: Boolean by remember { mutableStateOf(false) } + + SafeLaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is CropImageScreenEvent.Crop -> { + isProcessing = true + + withContext(Dispatchers.Default) { + croppedProfileImage = requireNotNull(profileImageCandidate).crop(event.rect) + } + + isProcessing = false + } + + is CropImageScreenEvent.Confirm -> { + val result = requireNotNull(croppedProfileImage) + repository.setProfileImageInEdit(result) + + shouldBack = true + } + + is CropImageScreenEvent.Cancel -> { + croppedProfileImage = null + } + } + } + } + + val candidate = profileImageCandidate + val cropped = croppedProfileImage + when { + candidate == null -> { + CropImageScreenState.Init + } + + cropped == null -> { + CropImageScreenState.Select( + profileImage = candidate, + isProcessing = isProcessing, + ) + } + + else -> { + CropImageScreenState.Confirm( + profileImage = cropped, + shouldBack = shouldBack, + ) + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt index 9d9d76159..0ad1c5ff1 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt @@ -147,11 +147,13 @@ const val ProfileCardShareButtonTestTag = "ProfileCardShareButtonTestTag" fun NavGraphBuilder.profileCardScreen( contentPadding: PaddingValues, onClickShareProfileCard: (String, ImageBitmap) -> Unit, + onNavigateToCropImage: () -> Unit, ) { composable(profileCardScreenRoute) { ProfileCardScreen( contentPadding = contentPadding, onClickShareProfileCard = onClickShareProfileCard, + onNavigateToCropImage = onNavigateToCropImage, ) } } @@ -210,12 +212,14 @@ data class ProfileCardScreenState( @Composable fun ProfileCardScreen( onClickShareProfileCard: (String, ImageBitmap) -> Unit, + onNavigateToCropImage: () -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { ProfileCardScreen( contentPadding = contentPadding, onClickShareProfileCard = onClickShareProfileCard, + onNavigateToCropImage = onNavigateToCropImage, modifier = modifier, eventFlow = rememberEventFlow(), ) @@ -226,9 +230,13 @@ fun ProfileCardScreen( internal fun ProfileCardScreen( contentPadding: PaddingValues, onClickShareProfileCard: (String, ImageBitmap) -> Unit, + onNavigateToCropImage: () -> Unit, modifier: Modifier = Modifier, eventFlow: EventFlow = rememberEventFlow(), - uiState: ProfileCardScreenState = profileCardScreenPresenter(eventFlow), + uiState: ProfileCardScreenState = profileCardScreenPresenter( + onNavigateToCropImage = onNavigateToCropImage, + events = eventFlow, + ), ) { val snackbarHostState = remember { SnackbarHostState() } val layoutDirection = LocalLayoutDirection.current @@ -330,6 +338,9 @@ internal fun ProfileCardScreen( onClickCreate = { eventFlow.tryEmit(EditScreenEvent.Create(it)) }, + onCropImage = { + eventFlow.tryEmit(EditScreenEvent.CropImage(it)) + }, contentPadding = padding, ) } @@ -375,6 +386,7 @@ internal fun EditScreen( onChangeLink: (String) -> Unit, onChangeImage: (String) -> Unit, onClickCreate: (ProfileCard.Exists) -> Unit, + onCropImage: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { @@ -385,9 +397,9 @@ internal fun EditScreen( val image by remember { derivedStateOf { imageByteArray?.toImageBitmap() } } var selectedCardType by rememberSaveable { mutableStateOf(uiState.cardType) } - val isValidInputs by remember { + val isValidInputs by remember(uiState.image) { derivedStateOf { - nickname.isNotEmpty() && occupation.isNotEmpty() && link.isNotEmpty() && image != null + nickname.isNotEmpty() && occupation.isNotEmpty() && link.isNotEmpty() && uiState.image != null } } @@ -457,16 +469,17 @@ internal fun EditScreen( Label(label = stringResource(ProfileCardRes.string.image)) Spacer(modifier = Modifier.height(12.dp)) ImagePickerWithError( - image = image, + image = uiState.image?.decodeBase64Bytes()?.toImageBitmap(), onSelectedImage = { - imageByteArray = it onChangeImage(it.toBase64()) }, errorMessage = profileCardError.imageError, onClearImage = { - imageByteArray = null onChangeImage("") }, + onCropImage = { + onCropImage(it.toBase64()) + }, ) } @@ -487,7 +500,7 @@ internal fun EditScreen( nickname = nickname, occupation = occupation, link = link, - image = imageByteArray?.toBase64() ?: "", + image = uiState.image ?: "", cardType = selectedCardType, ), ) @@ -592,6 +605,7 @@ private fun ImagePickerWithError( errorMessage: String, onSelectedImage: (ByteArray) -> Unit, onClearImage: () -> Unit, + onCropImage: (ByteArray) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -634,6 +648,7 @@ private fun ImagePickerWithError( } ?: run { PhotoPickerButton( onSelectedImage = onSelectedImage, + onCropImage = onCropImage, modifier = Modifier .padding(bottom = 20.dp) .testTag(ProfileCardSelectImageButtonTestTag), diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt index 367cc262b..b82f4752d 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt @@ -1,10 +1,12 @@ package io.github.droidkaigi.confsched.profilecard import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import conference_app_2024.feature.profilecard.generated.resources.add_validate_format import conference_app_2024.feature.profilecard.generated.resources.droidkaigi_logo @@ -20,6 +22,7 @@ import io.github.droidkaigi.confsched.compose.SafeLaunchedEffect import io.github.droidkaigi.confsched.droidkaigiui.providePresenterDefaults import io.github.droidkaigi.confsched.model.ProfileCard import io.github.droidkaigi.confsched.model.ProfileCardRepository +import io.github.droidkaigi.confsched.model.ProfileImage import io.github.droidkaigi.confsched.model.localProfileCardRepository import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.stringResource @@ -44,6 +47,9 @@ internal sealed interface EditScreenEvent : ProfileCardScreenEvent { ) : EditScreenEvent data object SelectImage : EditScreenEvent + + data class CropImage(val image: String) : EditScreenEvent + data class Create(val profileCard: ProfileCard.Exists) : EditScreenEvent } @@ -51,17 +57,21 @@ internal sealed interface CardScreenEvent : ProfileCardScreenEvent { data object Edit : CardScreenEvent } -internal fun ProfileCard.toEditUiState(): ProfileCardUiState.Edit { +internal fun ProfileCard.toEditUiState( + profileImageInEditString: String?, +): ProfileCardUiState.Edit { return when (this) { is ProfileCard.Exists -> ProfileCardUiState.Edit( nickname = nickname, occupation = occupation, link = link, - image = image, + image = profileImageInEditString, cardType = cardType, ) - ProfileCard.DoesNotExists, ProfileCard.Loading -> ProfileCardUiState.Edit() + ProfileCard.DoesNotExists, ProfileCard.Loading -> ProfileCardUiState.Edit( + image = profileImageInEditString, + ) } } @@ -82,6 +92,7 @@ internal fun ProfileCard.toCardUiState(): ProfileCardUiState.Card? { @OptIn(ExperimentalResourceApi::class) @Composable internal fun profileCardScreenPresenter( + onNavigateToCropImage: () -> Unit, events: EventFlow, repository: ProfileCardRepository = localProfileCardRepository(), ): ProfileCardScreenState = providePresenterDefaults { userMessageStateHolder -> @@ -105,17 +116,38 @@ internal fun profileCardScreenPresenter( stringResource(ProfileCardRes.string.image), ) + val navigateToCropImage by rememberUpdatedState(onNavigateToCropImage) val profileCard: ProfileCard by rememberUpdatedState(repository.profileCard()) + val profileImageInEdit: ProfileImage? by rememberUpdatedState(repository.profileImageInEdit()) + val profileImageInEditString: String? by remember { + derivedStateOf { + profileImageInEdit?.bytes?.toBase64() + } + } var isLoading: Boolean by remember { mutableStateOf(false) } - val editUiState: ProfileCardUiState.Edit by rememberUpdatedState(profileCard.toEditUiState()) + val editUiState: ProfileCardUiState.Edit by rememberUpdatedState( + profileCard.toEditUiState( + profileImageInEditString = profileImageInEditString, + ), + ) val cardUiState: ProfileCardUiState.Card? by rememberUpdatedState(profileCard.toCardUiState()) var cardError by remember { mutableStateOf(ProfileCardError()) } - var uiType: ProfileCardUiType by remember { mutableStateOf(ProfileCardUiType.Loading) } + var uiType: ProfileCardUiType by rememberSaveable { mutableStateOf(ProfileCardUiType.Loading) } // at first launch, if you have a profile card, show card ui SafeLaunchedEffect(profileCard) { - uiType = when (profileCard) { - is ProfileCard.Exists -> ProfileCardUiType.Card + if (uiType != ProfileCardUiType.Loading) return@SafeLaunchedEffect + + uiType = when (val card = profileCard) { + is ProfileCard.Exists -> { + repository.setProfileImageInEdit( + ProfileImage( + bytes = card.image.decodeBase64Bytes(), + ), + ) + ProfileCardUiType.Card + } + ProfileCard.DoesNotExists -> ProfileCardUiType.Edit ProfileCard.Loading -> ProfileCardUiType.Loading } @@ -166,7 +198,9 @@ internal fun profileCardScreenPresenter( // Only matches if the link is in this format "${http or https + ://}${sub domain + .}${domain}.${tld}/${sub directories}". // Protocol, sub domain and sub directories are optional. // ex. https://www.example.com/hogefuga/foobar - val invalidFormat = event.link.matches(Regex("^(?:https?://)?(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]{2,}(?:/\\S*)?\$")).not() + val invalidFormat = event.link + .matches(Regex("^(?:https?://)?(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]{2,}(?:/\\S*)?\$")) + .not() cardError = cardError.copy( linkError = if (event.link.isEmpty()) emptyLinkErrorString else if (invalidFormat) invalidLinkErrorString else "", ) @@ -176,6 +210,20 @@ internal fun profileCardScreenPresenter( cardError = cardError.copy( imageError = if (event.image.isEmpty()) emptyImageErrorString else "", ) + if (event.image.isEmpty()) { + repository.clearProfileImageInEdit() + } else { + repository.setProfileImageInEdit( + ProfileImage(bytes = event.image.decodeBase64Bytes()), + ) + } + } + + is EditScreenEvent.CropImage -> { + repository.setProfileImageCandidate( + ProfileImage(bytes = event.image.decodeBase64Bytes()), + ) + navigateToCropImage() } } } diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ImageCropAreaSelector.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ImageCropAreaSelector.kt new file mode 100644 index 000000000..5533610d8 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ImageCropAreaSelector.kt @@ -0,0 +1,88 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.preat.peekaboo.image.picker.toImageBitmap +import io.github.droidkaigi.confsched.model.ProfileImage +import io.github.droidkaigi.confsched.profilecard.TransformCalculator + +@Composable +internal fun ImageCropAreaSelector( + profileImage: ProfileImage, + transformCalculator: TransformCalculator, + scale: () -> Float, + offset: () -> Offset, + onTransform: (scale: Float, offset: Offset) -> Unit, + modifier: Modifier = Modifier, +) { + val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() } + val strokeOuterColor = MaterialTheme.colorScheme.onSurface + val strokeInnerColor = MaterialTheme.colorScheme.surface + + Image( + modifier = modifier + .fillMaxSize() + .pointerInput(profileImage) { + detectTransformGestures { _, pan, zoom, _ -> + val (newScale, newOffset) = transformCalculator.calculateNext( + oldScale = scale(), + oldOffset = offset(), + zoom = zoom, + pan = pan, + ) + + onTransform(newScale, newOffset) + } + } + .drawWithContent { + drawContent() + + val length = transformCalculator.cropRectLength + val outerRect = Size(length, length) + .toRect() + .translate(size.center) + .translate(-length / 2, -length / 2) + val innerRect = outerRect.deflate(strokeWidth) + + drawRect( + color = strokeOuterColor, + topLeft = outerRect.topLeft, + size = outerRect.size, + style = Stroke(width = strokeWidth), + ) + drawRect( + color = strokeInnerColor, + topLeft = innerRect.topLeft, + size = innerRect.size, + style = Stroke(width = strokeWidth), + ) + } + .graphicsLayer { + val currentScale = scale() + val currentOffset = offset() + + scaleX = currentScale + scaleY = currentScale + translationX = -currentOffset.x * currentScale + translationY = -currentOffset.y * currentScale + }, + bitmap = profileImage.bytes.toImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + ) +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/PhotoPickButton.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/PhotoPickButton.kt index 02ab3df2e..9350e61a8 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/PhotoPickButton.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/PhotoPickButton.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import com.preat.peekaboo.image.picker.ImagePickerLauncher import com.preat.peekaboo.image.picker.SelectionMode import com.preat.peekaboo.image.picker.rememberImagePickerLauncher import kotlinx.coroutines.CoroutineScope @@ -12,10 +13,14 @@ import kotlinx.coroutines.CoroutineScope @Composable internal fun PhotoPickerButton( onSelectedImage: (ByteArray) -> Unit, + onCropImage: (ByteArray) -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val imagePicker = rememberSingleImagePickerLauncher(onResult = onSelectedImage) + val imagePicker = rememberCroppingImagePickerLauncher( + onSelectedImage = onSelectedImage, + onCropImage = onCropImage, + ) OutlinedButton( onClick = imagePicker::launch, @@ -27,7 +32,14 @@ internal fun PhotoPickerButton( } @Composable -private fun rememberSingleImagePickerLauncher( +internal expect fun rememberCroppingImagePickerLauncher( + onCropImage: (ByteArray) -> Unit, + onSelectedImage: (ByteArray) -> Unit, + scope: CoroutineScope = rememberCoroutineScope(), +): ImagePickerLauncher + +@Composable +internal fun rememberSingleImagePickerLauncher( scope: CoroutineScope = rememberCoroutineScope(), onResult: (ByteArray) -> Unit, ) = rememberImagePickerLauncher( diff --git a/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt index 798e8ebaa..b659ab560 100644 --- a/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt +++ b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt @@ -28,6 +28,7 @@ fun profileCardViewController( contentPadding = PaddingValues( bottom = 30.dp, // Height of bottom tab bar ), + onNavigateToCropImage = { /* no action for iOS side */ }, ) } @@ -39,5 +40,8 @@ fun profileCardScreenPresenterStateFlow( events = events, repositories = repositories, ) { - profileCardScreenPresenter(events) + profileCardScreenPresenter( + onNavigateToCropImage = { /* no action for iOS side */ }, + events = events, + ) } diff --git a/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt new file mode 100644 index 000000000..ec290ded4 --- /dev/null +++ b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/SingleImagePickerLauncher.kt @@ -0,0 +1,16 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.runtime.Composable +import com.preat.peekaboo.image.picker.ImagePickerLauncher +import kotlinx.coroutines.CoroutineScope + +@Composable +internal actual fun rememberCroppingImagePickerLauncher( + onCropImage: (ByteArray) -> Unit, + onSelectedImage: (ByteArray) -> Unit, + scope: CoroutineScope, +): ImagePickerLauncher { + return rememberSingleImagePickerLauncher(scope) { + onSelectedImage(it) + } +}