Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crop image to square #952

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f2facf2
feat: Define ProfileImage to handle images and their cropping
warahiko Aug 31, 2024
e24fa7e
feat: Enable cropping for ProfileImage
warahiko Aug 31, 2024
8a14a0c
feat: Calculate size of ProfileImage
warahiko Aug 31, 2024
8a47e49
feat: Cache ProfileImage in ProfileCardRepository
warahiko Aug 31, 2024
6949bec
feat: Add CropImageScreen and navigate when the selected image is not…
warahiko Sep 1, 2024
9c677ac
feat: Crop image in CropImageScreen and save it to the repository
warahiko Sep 1, 2024
6c1472a
feat: Apply Cropped image in ProfileCardEditScreen
warahiko Sep 1, 2024
f7e92c9
feat: implement for iOS
warahiko Sep 3, 2024
468d810
feat: Change to rememberSaveable to retain input inputs during image …
warahiko Sep 3, 2024
be8e272
feat: Temporarily split processing between Android and iOS
warahiko Sep 3, 2024
5f5d498
Merge branch 'main' into feature/crop_image
takahirom Sep 4, 2024
320c57a
style: Declare properties before methods
warahiko Sep 5, 2024
ecd26ce
fix: Incorrect modifier specification
warahiko Sep 5, 2024
1155000
style: Add trailing comma
warahiko Sep 5, 2024
3ced7ce
style: Declare parameters without defaults before parameters with def…
warahiko Sep 5, 2024
f90429c
test: Fix compile error
warahiko Sep 5, 2024
203d189
refactor: Rename eventEmitter -> eventFlow
warahiko Sep 6, 2024
e9e04d2
refactor: Use a flag in UiState instead of passing a callback to the …
warahiko Sep 6, 2024
044cb2f
refactor: Rename onConfirm -> onBackWithConfirm
warahiko Sep 6, 2024
e22abae
test: Check the launch of CropImageScreen
warahiko Sep 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -189,6 +191,11 @@ private fun KaigiNavHost(
onTimetableItemClick = navController::navigateToTimetableItemDetailScreen,
contentPadding = PaddingValues(),
)

cropImageScreens(
onNavigationIconClick = navController::popBackStack,
onBackWithConfirm = navController::popBackStack,
)
}
}
}
Expand Down Expand Up @@ -267,6 +274,7 @@ private fun NavGraphBuilder.mainScreen(
profileCardScreen(
contentPadding = contentPadding,
onClickShareProfileCard = externalNavController::onShareProfileCardClick,
onNavigateToCropImage = { navController.navigate(cropImageScreenRoute) },
)
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,6 +253,11 @@ private fun KaigiNavHost(
onTimetableItemClick = navController::navigateToTimetableItemDetailScreen,
contentPadding = PaddingValues(),
)

cropImageScreens(
onNavigationIconClick = navController::popBackStack,
onBackWithConfirm = navController::popBackStack,
)
}
}

Expand Down Expand Up @@ -327,6 +334,7 @@ private fun NavGraphBuilder.mainScreen(
profileCardScreen(
contentPadding = contentPadding,
onClickShareProfileCard = externalNavController::onShareProfileCardClick,
onNavigateToCropImage = { navController.navigate(cropImageScreenRoute) },
)
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +20,9 @@ public class DefaultProfileCardRepository(
private val profileCardDataStore: ProfileCardDataStore,
private val ioDispatcher: CoroutineDispatcher,
) : ProfileCardRepository {
private val profileImageInEditStateFlow = MutableStateFlow<ProfileImage?>(null)
private val profileImageCandidateStateFlow = MutableStateFlow<ProfileImage?>(null)

@Composable
override fun profileCard(): ProfileCard {
val profileCard by remember {
Expand All @@ -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(),
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Comment on lines +18 to +29
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is necessary to resolve the warning.

}

expect val ByteArray.imageSize: IntSize
expect fun ByteArray.crop(rect: IntRect): ByteArray
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ProfileCardScreenRobot @Inject constructor(
KaigiTheme {
ProfileCardScreen(
onClickShareProfileCard = { _, _ -> },
onNavigateToCropImage = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<CropImageScreenRobot>,
) {

@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<DescribedBehavior<CropImageScreenRobot>> {
return describeBehaviors(name = "CropImageScreen") {
describe("when launch") {
doIt {
setupScreenContent()
}
itShould("show screen") {
captureScreenWithChecks {
checkScreenDisplayed()
}
}
}
}
}
}
}
Loading
Loading