diff --git a/core/common/src/androidMain/kotlin/io/github/droidkaigi/confsched/di/DispatcherModule.kt b/core/common/src/androidMain/kotlin/io/github/droidkaigi/confsched/di/DispatcherModule.kt new file mode 100644 index 000000000..2c89e3441 --- /dev/null +++ b/core/common/src/androidMain/kotlin/io/github/droidkaigi/confsched/di/DispatcherModule.kt @@ -0,0 +1,20 @@ +package io.github.droidkaigi.confsched.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +public annotation class IoDispatcher + +@InstallIn(SingletonComponent::class) +@Module +public class DispatcherModule { + @IoDispatcher + @Provides + public fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 214907121..4fb0b13f7 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { implementation(libs.ktorKotlinxSerialization) implementation(libs.ktorContentNegotiation) implementation(libs.kermit) + implementation(libs.qrcodeKotlin) } } diff --git a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/ProfileCardRepositoryModule.kt b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/ProfileCardRepositoryModule.kt index 9005f1703..f2487979d 100644 --- a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/ProfileCardRepositoryModule.kt +++ b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched/data/profilecard/ProfileCardRepositoryModule.kt @@ -8,7 +8,9 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap import io.github.droidkaigi.confsched.data.di.RepositoryQualifier +import io.github.droidkaigi.confsched.di.IoDispatcher import io.github.droidkaigi.confsched.model.ProfileCardRepository +import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Singleton @Module @@ -25,8 +27,9 @@ internal abstract class ProfileCardRepositoryModule { @Provides fun provideProfileCardRepository( profileCardDataStore: ProfileCardDataStore, + @IoDispatcher ioDispatcher: CoroutineDispatcher, ): ProfileCardRepository { - return DefaultProfileCardRepository(profileCardDataStore) + return DefaultProfileCardRepository(profileCardDataStore, ioDispatcher) } } } 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 8aed95780..42ee4c579 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,9 +6,17 @@ 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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.getDrawableResourceBytes +import org.jetbrains.compose.resources.getSystemResourceEnvironment +import qrcode.QRCode public class DefaultProfileCardRepository( private val profileCardDataStore: ProfileCardDataStore, + private val ioDispatcher: CoroutineDispatcher, ) : ProfileCardRepository { @Composable override fun profileCard(): ProfileCard { @@ -22,4 +30,18 @@ public class DefaultProfileCardRepository( override suspend fun save(profileCard: ProfileCard.Exists) { profileCardDataStore.save(profileCard) } + + @OptIn(ExperimentalResourceApi::class) + override suspend fun loadQrCodeImageByteArray(link: String, centerLogoRes: DrawableResource): ByteArray { + return withContext(ioDispatcher) { + val logoImage = getDrawableResourceBytes( + environment = getSystemResourceEnvironment(), + resource = centerLogoRes, + ) + QRCode.ofSquares() + .withLogo(logoImage, 400, 400) + .build(link) + .renderToBytes() + } + } } diff --git a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt index 238836224..4d88cbcb9 100644 --- a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt +++ b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module import platform.Foundation.NSDocumentDirectory @@ -165,6 +166,11 @@ public val dataModule: Module = module { DefaultProfileCardDataStore(dataStore) } + // Since Kotlin/Native doesn't support Dispatchers.IO, we use Dispatchers.Default instead. + single(named("IoDispatcher")) { + Dispatchers.Default + } + singleOf(::DefaultAuthApi) bind AuthApi::class singleOf(::DefaultSessionsApiClient) bind SessionsApiClient::class singleOf(::DefaultContributorsApiClient) bind ContributorsApiClient::class @@ -179,7 +185,12 @@ public val dataModule: Module = module { singleOf(::DefaultSponsorsRepository) bind SponsorsRepository::class singleOf(::DefaultEventMapRepository) bind EventMapRepository::class singleOf(::DefaultAboutRepository) bind AboutRepository::class - singleOf(::DefaultProfileCardRepository) bind ProfileCardRepository::class + single { + DefaultProfileCardRepository( + profileCardDataStore = get(), + ioDispatcher = get(named("IoDispatcher")), + ) + } single { DefaultRepositories( mapOf( 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 2bcda76b3..3ba6ca212 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 @@ -2,11 +2,13 @@ package io.github.droidkaigi.confsched.model import androidx.compose.runtime.Composable import io.github.droidkaigi.confsched.model.compositionlocal.LocalRepositories +import org.jetbrains.compose.resources.DrawableResource interface ProfileCardRepository { @Composable fun profileCard(): ProfileCard suspend fun save(profileCard: ProfileCard.Exists) + suspend fun loadQrCodeImageByteArray(link: String, centerLogoRes: DrawableResource): ByteArray } @Composable diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestDispatcherModule.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestDispatcherModule.kt index ee9b59219..b928c6caf 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestDispatcherModule.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/data/TestDispatcherModule.kt @@ -2,17 +2,24 @@ package io.github.droidkaigi.confsched.testing.data import dagger.Module import dagger.Provides -import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.github.droidkaigi.confsched.di.DispatcherModule +import io.github.droidkaigi.confsched.di.IoDispatcher +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import javax.inject.Singleton @Module -@InstallIn(SingletonComponent::class) +@TestInstallIn(components = [SingletonComponent::class], replaces = [DispatcherModule::class]) class TestDispatcherModule { @Provides @Singleton fun provideTestDispatcher(): TestDispatcher = StandardTestDispatcher() + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = StandardTestDispatcher() } 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 6977cbae4..6fd00f78b 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 @@ -18,7 +18,6 @@ import io.github.droidkaigi.confsched.profilecard.ProfileCardLinkTextFieldTestTa import io.github.droidkaigi.confsched.profilecard.ProfileCardNicknameTextFieldTestTag import io.github.droidkaigi.confsched.profilecard.ProfileCardOccupationTextFieldTestTag import io.github.droidkaigi.confsched.profilecard.ProfileCardScreen -import io.github.droidkaigi.confsched.profilecard.ProfileCardShareButtonTestTag import io.github.droidkaigi.confsched.profilecard.component.ProfileCardFlipCardBackTestTag import io.github.droidkaigi.confsched.profilecard.component.ProfileCardFlipCardFrontTestTag import io.github.droidkaigi.confsched.profilecard.component.ProfileCardFlipCardTestTag @@ -141,12 +140,6 @@ class ProfileCardScreenRobot @Inject constructor( .assertTextEquals(link) } - fun checkShareProfileCardButtonEnabled() { - composeTestRule - .onNode(hasTestTag(ProfileCardShareButtonTestTag)) - .assertIsEnabled() - } - fun checkCardScreenDisplayed() { composeTestRule .onNode(hasTestTag(ProfileCardCardScreenTestTag)) diff --git a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt index 522926c75..bfbebf0f8 100644 --- a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt +++ b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt @@ -59,7 +59,6 @@ class ProfileCardScreenTest( } itShould("show card screen") { captureScreenWithChecks { - checkShareProfileCardButtonEnabled() checkCardScreenDisplayed() checkProfileCardFrontDisplayed() } 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 2be04753f..d91345265 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 @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -52,12 +51,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -69,8 +68,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.layer.GraphicsLayer -import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -85,6 +82,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import co.touchlab.kermit.Logger +import coil3.compose.rememberAsyncImagePainter import com.preat.peekaboo.image.picker.toImageBitmap import conference_app_2024.feature.profilecard.generated.resources.add_image import conference_app_2024.feature.profilecard.generated.resources.card_type @@ -112,10 +110,10 @@ import io.github.droidkaigi.confsched.droidkaigiui.component.AnimatedTextTopAppB import io.github.droidkaigi.confsched.droidkaigiui.component.resetScroll import io.github.droidkaigi.confsched.model.ProfileCard import io.github.droidkaigi.confsched.model.ProfileCardType -import io.github.droidkaigi.confsched.profilecard.component.CapturableCard import io.github.droidkaigi.confsched.profilecard.component.FlipCard import io.github.droidkaigi.confsched.profilecard.component.InvertSystemBarAppearance import io.github.droidkaigi.confsched.profilecard.component.PhotoPickerButton +import io.github.droidkaigi.confsched.profilecard.component.ShareableCard import io.ktor.util.decodeBase64Bytes import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource @@ -195,6 +193,7 @@ internal data class ProfileCardScreenState( val cardError: ProfileCardError, val uiType: ProfileCardUiType, val userMessageStateHolder: UserMessageStateHolder, + val qrCodeImageByteArray: ByteArray? = null, ) @Composable @@ -333,6 +332,7 @@ internal fun ProfileCardScreen( }, contentPadding = padding, isCreated = true, + qrCodeImageByte = uiState.qrCodeImageByteArray, ) } } @@ -469,10 +469,10 @@ internal fun EditScreen( } @OptIn(ExperimentalEncodingApi::class) -private fun ByteArray.toBase64(): String = Base64.encode(this) +internal fun ByteArray.toBase64(): String = Base64.encode(this) @OptIn(ExperimentalEncodingApi::class) -private fun String.decodeBase64Bytes(): ByteArray = Base64.decode(this) +internal fun String.decodeBase64Bytes(): ByteArray = Base64.decode(this) @Composable internal fun Label(label: String) { @@ -662,7 +662,7 @@ private fun CardTypeImage( ) } -fun Modifier.selectedBorder( +private fun Modifier.selectedBorder( isSelected: Boolean, selectedBorderColor: Color, vectorPainter: VectorPainter, @@ -705,6 +705,7 @@ internal fun CardScreen( onClickEdit: () -> Unit, onClickShareProfileCard: (ImageBitmap) -> Unit, scrollBehavior: TopAppBarScrollBehavior, + qrCodeImageByte: ByteArray?, modifier: Modifier = Modifier, isCreated: Boolean = false, contentPadding: PaddingValues = PaddingValues(16.dp), @@ -712,6 +713,8 @@ internal fun CardScreen( val coroutineScope = rememberCoroutineScope() val graphicsLayer = rememberGraphicsLayer() var isShareReady by remember { mutableStateOf(false) } + val profileCardImagePainter = rememberProfileImagePainter(uiState.image) + val qrCodeImagePainter = rememberAsyncImagePainter(qrCodeImageByte) // The background of this screen is light, contrasting any other screen in the app. // Invert the content color of system bars to accommodate this unique property. @@ -720,10 +723,11 @@ internal fun CardScreen( ProvideProfileCardTheme(uiState.cardType.toString()) { Box { // Not for display, for sharing - ShareableProfileCard( + ShareableCard( uiState = uiState, graphicsLayer = graphicsLayer, - contentPadding = contentPadding, + profileImagePainter = profileCardImagePainter, + qrCodeImagePainter = qrCodeImagePainter, onReadyShare = { Logger.d { "Ready to share" } isShareReady = true @@ -747,6 +751,8 @@ internal fun CardScreen( ) { FlipCard( uiState = uiState, + profileImagePainter = profileCardImagePainter, + qrCodeImagePainter = qrCodeImagePainter, isCreated = isCreated, ) Spacer(Modifier.height(32.dp)) @@ -758,7 +764,10 @@ internal fun CardScreen( onClickShareProfileCard(graphicsLayer.toImageBitmap()) } }, - colors = ButtonDefaults.buttonColors(containerColor = Color.White), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + disabledContainerColor = Color.White, + ), border = if (uiState.cardType == ProfileCardType.None) { BorderStroke( 0.5.dp, @@ -804,58 +813,8 @@ internal fun CardScreen( } @Composable -private fun ShareableProfileCard( - uiState: ProfileCardUiState.Card, - graphicsLayer: GraphicsLayer, - contentPadding: PaddingValues, - onReadyShare: () -> Unit, -) { - var frontImage: ImageBitmap? by remember { mutableStateOf(null) } - var backImage: ImageBitmap? by remember { mutableStateOf(null) } - CapturableCard( - uiState = uiState, - onCaptured = { front, back -> - frontImage = front - backImage = back - onReadyShare() - }, - ) - Box( - modifier = Modifier.padding(contentPadding) - .drawWithContent { - graphicsLayer.record { - this@drawWithContent.drawContent() - } - drawLayer(graphicsLayer) - }, - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .background(LocalProfileCardTheme.current.primaryColor) - .padding(vertical = 50.dp), - ) { - backImage?.let { - Image( - bitmap = it, - contentDescription = null, - modifier = Modifier - .offset(x = 70.dp, y = 15.dp) - .rotate(10f) - .size(150.dp, 190.dp), - ) - } - frontImage?.let { - Image( - bitmap = it, - contentDescription = null, - modifier = Modifier - .offset(x = (-70).dp, y = (-15).dp) - .rotate(-10f) - .size(150.dp, 190.dp), - ) - } - } - } -} +private fun rememberProfileImagePainter( + imageBase64String: String, +) = rememberAsyncImagePainter( + model = rememberSaveable { imageBase64String.decodeBase64Bytes() }, +) 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 614700b55..796ce26ce 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 @@ -7,6 +7,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState 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 import conference_app_2024.feature.profilecard.generated.resources.enter_validate_format import conference_app_2024.feature.profilecard.generated.resources.image import conference_app_2024.feature.profilecard.generated.resources.link @@ -19,6 +20,7 @@ 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.localProfileCardRepository +import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.stringResource internal sealed interface ProfileCardScreenEvent @@ -48,7 +50,7 @@ internal sealed interface CardScreenEvent : ProfileCardScreenEvent { data object Edit : CardScreenEvent } -private fun ProfileCard.toEditUiState(): ProfileCardUiState.Edit { +internal fun ProfileCard.toEditUiState(): ProfileCardUiState.Edit { return when (this) { is ProfileCard.Exists -> ProfileCardUiState.Edit( nickname = nickname, @@ -62,7 +64,7 @@ private fun ProfileCard.toEditUiState(): ProfileCardUiState.Edit { } } -private fun ProfileCard.toCardUiState(): ProfileCardUiState.Card? { +internal fun ProfileCard.toCardUiState(): ProfileCardUiState.Card? { return when (this) { is ProfileCard.Exists -> ProfileCardUiState.Card( nickname = nickname, @@ -76,6 +78,7 @@ private fun ProfileCard.toCardUiState(): ProfileCardUiState.Card? { } } +@OptIn(ExperimentalResourceApi::class) @Composable internal fun profileCardScreenPresenter( events: EventFlow, @@ -114,6 +117,18 @@ internal fun profileCardScreenPresenter( } } + var qrCodeImageByteArray by remember { mutableStateOf(ByteArray(0)) } + val link = cardUiState?.link + SafeLaunchedEffect(link) { + link?.let { link -> + qrCodeImageByteArray = repository + .loadQrCodeImageByteArray( + link = link, + centerLogoRes = ProfileCardRes.drawable.droidkaigi_logo, + ) + } + } + EventEffect(events) { event -> when (event) { is CardScreenEvent.Edit -> { @@ -164,5 +179,6 @@ internal fun profileCardScreenPresenter( cardError = cardError, uiType = uiType, userMessageStateHolder = userMessageStateHolder, + qrCodeImageByteArray = qrCodeImageByteArray, ) } diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt new file mode 100644 index 000000000..5b9b5f81b --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt @@ -0,0 +1,76 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import coil3.compose.AsyncImagePainter +import io.github.droidkaigi.confsched.droidkaigiui.compositionlocal.LocalClock +import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card +import kotlinx.coroutines.flow.first + +@Composable +internal fun BackgroundCapturableCardBack( + uiState: Card, + qrCodeImagePainter: AsyncImagePainter, + onCaptured: (ImageBitmap) -> Unit, +) { + val clock = LocalClock.current + val graphicsLayer: GraphicsLayer = rememberGraphicsLayer() + var lastCaptureTime by remember { mutableStateOf(0L) } + + LaunchedEffect(lastCaptureTime) { + if (lastCaptureTime == 0L) { + return@LaunchedEffect + } + qrCodeImagePainter.state.first { it is AsyncImagePainter.State.Success } + Logger.d { "BackgroundCapturableCardBack: onCaptured" } + onCaptured(graphicsLayer.toImageBitmap()) + } + + Box( + modifier = Modifier + .drawWithCache { + onDrawWithContent { + graphicsLayer.record { + this@onDrawWithContent.drawContent() + } + if (graphicsLayer.size.height > 0 && graphicsLayer.size.width > 0) { + lastCaptureTime = clock.now().toEpochMilliseconds() + } + drawLayer(graphicsLayer) + } + }, + ) { + FlipCardBack( + uiState, + qrCodeImagePainter, + modifier = Modifier + .size(width = 300.dp, height = 380.dp) + .border( + 3.dp, + Color.Black, + RoundedCornerShape(8.dp), + ) + .graphicsLayer { + rotationY = 180f + }, + ) + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt new file mode 100644 index 000000000..57a290bfa --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt @@ -0,0 +1,71 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import coil3.compose.AsyncImagePainter +import io.github.droidkaigi.confsched.droidkaigiui.compositionlocal.LocalClock +import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card +import kotlinx.coroutines.flow.first + +@Composable +internal fun BackgroundCapturableCardFront( + uiState: Card, + profileImagePainter: AsyncImagePainter, + onCaptured: (ImageBitmap) -> Unit, +) { + val clock = LocalClock.current + val graphicsLayer = rememberGraphicsLayer() + var lastCaptureTime by remember { mutableStateOf(0L) } + + LaunchedEffect(lastCaptureTime) { + if (lastCaptureTime == 0L) { + return@LaunchedEffect + } + profileImagePainter.state.first { it is AsyncImagePainter.State.Success } + Logger.d { "BackgroundCapturableCardFront: onCaptured" } + onCaptured(graphicsLayer.toImageBitmap()) + } + + Box( + modifier = Modifier + .drawWithCache { + onDrawWithContent { + graphicsLayer.record { + this@onDrawWithContent.drawContent() + } + if (graphicsLayer.size.height > 0 && graphicsLayer.size.width > 0) { + lastCaptureTime = clock.now().toEpochMilliseconds() + } + drawLayer(graphicsLayer) + } + }, + ) { + FlipCardFront( + uiState, + profileImagePainter, + modifier = Modifier + .size(width = 300.dp, height = 380.dp) + .border( + 3.dp, + Color.Black, + RoundedCornerShape(8.dp), + ), + ) + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt index de3ddb4ca..1ebe228e6 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt @@ -3,22 +3,10 @@ package io.github.droidkaigi.confsched.profilecard.component import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -27,71 +15,28 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import co.touchlab.kermit.Logger -import com.preat.peekaboo.image.picker.toImageBitmap -import conference_app_2024.feature.profilecard.generated.resources.card_back_blue -import conference_app_2024.feature.profilecard.generated.resources.card_back_green -import conference_app_2024.feature.profilecard.generated.resources.card_back_orange -import conference_app_2024.feature.profilecard.generated.resources.card_back_pink -import conference_app_2024.feature.profilecard.generated.resources.card_back_white -import conference_app_2024.feature.profilecard.generated.resources.card_back_yellow -import conference_app_2024.feature.profilecard.generated.resources.card_front_blue -import conference_app_2024.feature.profilecard.generated.resources.card_front_green -import conference_app_2024.feature.profilecard.generated.resources.card_front_orange -import conference_app_2024.feature.profilecard.generated.resources.card_front_pink -import conference_app_2024.feature.profilecard.generated.resources.card_front_white -import conference_app_2024.feature.profilecard.generated.resources.card_front_yellow -import conference_app_2024.feature.profilecard.generated.resources.droidkaigi_logo -import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme -import io.github.droidkaigi.confsched.designsystem.theme.LocalProfileCardTheme -import io.github.droidkaigi.confsched.designsystem.theme.ProvideProfileCardTheme import io.github.droidkaigi.confsched.droidkaigiui.WithDeviceOrientation -import io.github.droidkaigi.confsched.model.ProfileCard -import io.github.droidkaigi.confsched.model.ProfileCardType -import io.github.droidkaigi.confsched.model.fake -import io.github.droidkaigi.confsched.profilecard.ProfileCardRes import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card import io.github.droidkaigi.confsched.profilecard.hologramaticEffect -import io.ktor.util.decodeBase64Bytes import kotlinx.coroutines.delay -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.getDrawableResourceBytes -import org.jetbrains.compose.resources.getSystemResourceEnvironment -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview -import qrcode.QRCode const val ProfileCardFlipCardTestTag = "ProfileCardFlipCardTestTag" -const val ProfileCardFlipCardFrontTestTag = "ProfileCardFlipCardFrontTestTag" -const val ProfileCardFlipCardBackTestTag = "ProfileCardFlipCardBackTestTag" -@OptIn(ExperimentalResourceApi::class) @Composable internal fun FlipCard( uiState: Card, + profileImagePainter: Painter, + qrCodeImagePainter: Painter, modifier: Modifier = Modifier, isCreated: Boolean = false, ) { + var initialRotation = rememberAnimatedInitialRotation(isCreated) var isFlipped by remember { mutableStateOf(false) } - var isCreated by rememberSaveable { mutableStateOf(isCreated) } - var initialRotation by remember { mutableStateOf(0f) } val rotation by animateFloatAsState( targetValue = if (isFlipped) 180f else initialRotation, animationSpec = tween( @@ -100,34 +45,6 @@ internal fun FlipCard( ), ) val isBack by remember { derivedStateOf { rotation > 90f } } - val targetRotation by animateFloatAsState( - targetValue = 30f, - animationSpec = tween( - durationMillis = 400, - easing = FastOutSlowInEasing, - ), - ) - val targetRotation2 by animateFloatAsState( - targetValue = 0f, - animationSpec = tween( - durationMillis = 400, - easing = FastOutSlowInEasing, - ), - ) - var logoImage by remember { mutableStateOf(ByteArray(0)) } - - LaunchedEffect(Unit) { - logoImage = getDrawableResourceBytes( - environment = getSystemResourceEnvironment(), - resource = ProfileCardRes.drawable.droidkaigi_logo, - ) - if (isCreated) { - initialRotation = targetRotation - delay(400) - initialRotation = targetRotation2 - isCreated = false - } - } Card( modifier = modifier @@ -140,252 +57,14 @@ internal fun FlipCard( }, elevation = CardDefaults.cardElevation(10.dp), ) { - val profileImage = remember { uiState.image.decodeBase64Bytes().toImageBitmap() } - val imageBitmap = remember(logoImage) { - QRCode.ofSquares() - .withLogo(logoImage, 400, 400) - .build(uiState.link) - .renderToBytes().toImageBitmap() - } - if (isBack) { // Back - FlipCardBack(uiState, imageBitmap) + FlipCardBack(uiState, qrCodeImagePainter) } else { // Front WithDeviceOrientation { FlipCardFront( modifier = Modifier.hologramaticEffect(this@WithDeviceOrientation), uiState = uiState, - profileImage = profileImage, - ) - } - } - } -} - -@OptIn(ExperimentalResourceApi::class) -@Composable -internal fun CapturableCard( - uiState: Card, - onCaptured: (ImageBitmap, ImageBitmap) -> Unit, -) { - val graphicsLayerFront = rememberGraphicsLayer() - val graphicsLayerBack = rememberGraphicsLayer() - val profileImage = remember { uiState.image.decodeBase64Bytes().toImageBitmap() } - var logoImage by remember { mutableStateOf(ByteArray(0)) } - val imageBitmap = remember(logoImage) { - QRCode.ofSquares() - .withLogo(logoImage, 400, 400) - .build(uiState.link) - .renderToBytes().toImageBitmap() - } - var isFrontCaptured by remember { mutableStateOf(false) } - var isBackCaptured by remember { mutableStateOf(false) } - var isFrontSizeNonZero by remember { mutableStateOf(false) } - var isBackSizeNonZero by remember { mutableStateOf(false) } - - LaunchedEffect(isFrontCaptured, isBackCaptured, isFrontSizeNonZero, isBackSizeNonZero) { - // In ComposableMultiplatform, an ImageBitmap is not Null, but may come with a size of 0. - // If the process reaches the Image's Composable with a size of 0, the application will crash with the following error. - // Uncaught Kotlin exception: kotlin.IllegalStateException: Size is unspecified - Logger.d { - "isFrontCaptured: $isFrontCaptured, isBackCaptured: $isBackCaptured, isFrontSizeNonZero: $isFrontSizeNonZero, isBackSizeNonZero: $isBackSizeNonZero" - } - if ( - isFrontCaptured.not() || - isBackCaptured.not() || - isFrontSizeNonZero.not() || - isBackSizeNonZero.not() - ) { - return@LaunchedEffect - } - - if (logoImage.isEmpty()) { - logoImage = getDrawableResourceBytes( - environment = getSystemResourceEnvironment(), - resource = ProfileCardRes.drawable.droidkaigi_logo, - ) - } - // after qr code rendered with logo, tell the event to parent component - onCaptured(graphicsLayerFront.toImageBitmap(), graphicsLayerBack.toImageBitmap()) - } - - Box { - Box( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - graphicsLayerFront.record { - this@onDrawWithContent.drawContent() - } - isFrontSizeNonZero = - graphicsLayerFront.size.width > 0 && graphicsLayerFront.size.height > 0 - drawLayer(graphicsLayerFront) - } - } - .onGloballyPositioned { - isFrontCaptured = true - Logger.d { "graphicsLayerFront:$graphicsLayerFront" } - }, - ) { - FlipCardFront( - uiState, - profileImage = profileImage, - modifier = Modifier - .size(width = 300.dp, height = 380.dp) - .border( - 3.dp, - Color.Black, - RoundedCornerShape(8.dp), - ), - ) - } - Box( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - graphicsLayerBack.record { - this@onDrawWithContent.drawContent() - } - isBackSizeNonZero = - graphicsLayerBack.size.width > 0 && graphicsLayerBack.size.height > 0 - drawLayer(graphicsLayerBack) - } - } - .onGloballyPositioned { - isBackCaptured = true - }, - ) { - FlipCardBack( - uiState, - imageBitmap, - modifier = Modifier - .size(width = 300.dp, height = 380.dp) - .border( - 3.dp, - Color.Black, - RoundedCornerShape(8.dp), - ) - .graphicsLayer { - rotationY = 180f - }, - ) - } - } -} - -@Composable -private fun FlipCardFront( - uiState: Card, - profileImage: ImageBitmap, - modifier: Modifier = Modifier, -) { - val background = when (uiState.cardType) { - ProfileCardType.Iguana -> ProfileCardRes.drawable.card_front_green - ProfileCardType.Hedgehog -> ProfileCardRes.drawable.card_front_orange - ProfileCardType.Giraffe -> ProfileCardRes.drawable.card_front_yellow - ProfileCardType.Flamingo -> ProfileCardRes.drawable.card_front_pink - ProfileCardType.Jellyfish -> ProfileCardRes.drawable.card_front_blue - ProfileCardType.None -> ProfileCardRes.drawable.card_front_white - } - val namePrimaryColor = LocalProfileCardTheme.current.primaryColor - Box( - modifier = modifier - .testTag(ProfileCardFlipCardFrontTestTag) - .fillMaxSize(), - ) { - Image( - painter = painterResource(background), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(103.dp)) - Image( - bitmap = profileImage, - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .size(131.dp), - ) - Spacer(Modifier.height(12.dp)) - Text( - text = uiState.occupation, - style = MaterialTheme.typography.titleMedium, - color = Color.White, - maxLines = 1, - ) - Text( - text = buildAnnotatedString { - withStyle( - SpanStyle( - brush = Brush.verticalGradient(listOf(Color.White, namePrimaryColor)), - ), - ) { - append(uiState.nickname) - } - }, - style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - ) - } - } -} - -@Composable -private fun FlipCardBack( - uiState: Card, - bitmap: ImageBitmap, - modifier: Modifier = Modifier, -) { - val background = when (uiState.cardType) { - ProfileCardType.Iguana -> ProfileCardRes.drawable.card_back_green - ProfileCardType.Hedgehog -> ProfileCardRes.drawable.card_back_orange - ProfileCardType.Giraffe -> ProfileCardRes.drawable.card_back_yellow - ProfileCardType.Flamingo -> ProfileCardRes.drawable.card_back_pink - ProfileCardType.Jellyfish -> ProfileCardRes.drawable.card_back_blue - ProfileCardType.None -> ProfileCardRes.drawable.card_back_white - } - Box( - modifier = modifier - .testTag(ProfileCardFlipCardBackTestTag) - .fillMaxSize() - .graphicsLayer { - rotationY = 180f - }, - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(background), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - Image( - bitmap = bitmap, - contentDescription = null, - modifier = Modifier.size(160.dp), - ) - } -} - -@Composable -@Preview -fun FlipCardFrontPreview() { - val uiState = ProfileCard.Exists.fake().let { (nickname, occupation, link, image, cardType) -> - Card(nickname, occupation, link, image, cardType) - } - val profileImage = uiState.image.decodeBase64Bytes().toImageBitmap() - - KaigiTheme { - Surface(modifier = Modifier.size(300.dp, 380.dp)) { - ProvideProfileCardTheme(uiState.cardType.name) { - FlipCardFront( - uiState = uiState, - profileImage = profileImage, + profileImagePainter = profileImagePainter, ) } } @@ -393,24 +72,31 @@ fun FlipCardFrontPreview() { } @Composable -@Preview -fun FlipCardBackPreview() { - val uiState = ProfileCard.Exists.fake().let { (nickname, occupation, link, image, cardType) -> - Card(nickname, occupation, link, image, cardType) - } +private fun rememberAnimatedInitialRotation(isCreated: Boolean): Float { + var isCreated by rememberSaveable { mutableStateOf(isCreated) } + var initialRotation by remember { mutableStateOf(0f) } + val targetRotation by animateFloatAsState( + targetValue = 30f, + animationSpec = tween( + durationMillis = 400, + easing = FastOutSlowInEasing, + ), + ) + val targetRotation2 by animateFloatAsState( + targetValue = 0f, + animationSpec = tween( + durationMillis = 400, + easing = FastOutSlowInEasing, + ), + ) - KaigiTheme { - Surface( - modifier = Modifier - .size(300.dp, 380.dp) - .graphicsLayer { - rotationY = 180f - }, - ) { - FlipCardBack( - uiState = uiState, - bitmap = QRCode.ofSquares().build(uiState.link).renderToBytes().toImageBitmap(), - ) + LaunchedEffect(Unit) { + if (isCreated) { + initialRotation = targetRotation + delay(400) + initialRotation = targetRotation2 + isCreated = false } } + return initialRotation } diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardBack.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardBack.kt new file mode 100644 index 000000000..94a82e773 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardBack.kt @@ -0,0 +1,100 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.preat.peekaboo.image.picker.toImageBitmap +import conference_app_2024.feature.profilecard.generated.resources.card_back_blue +import conference_app_2024.feature.profilecard.generated.resources.card_back_green +import conference_app_2024.feature.profilecard.generated.resources.card_back_orange +import conference_app_2024.feature.profilecard.generated.resources.card_back_pink +import conference_app_2024.feature.profilecard.generated.resources.card_back_white +import conference_app_2024.feature.profilecard.generated.resources.card_back_yellow +import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.model.ProfileCard +import io.github.droidkaigi.confsched.model.ProfileCardType.Flamingo +import io.github.droidkaigi.confsched.model.ProfileCardType.Giraffe +import io.github.droidkaigi.confsched.model.ProfileCardType.Hedgehog +import io.github.droidkaigi.confsched.model.ProfileCardType.Iguana +import io.github.droidkaigi.confsched.model.ProfileCardType.Jellyfish +import io.github.droidkaigi.confsched.model.ProfileCardType.None +import io.github.droidkaigi.confsched.model.fake +import io.github.droidkaigi.confsched.profilecard.ProfileCardRes +import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card +import io.github.droidkaigi.confsched.profilecard.toCardUiState +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import qrcode.QRCode + +const val ProfileCardFlipCardBackTestTag = "ProfileCardFlipCardBackTestTag" + +@Composable +internal fun FlipCardBack( + uiState: Card, + painter: Painter, + modifier: Modifier = Modifier, +) { + val background = when (uiState.cardType) { + Iguana -> ProfileCardRes.drawable.card_back_green + Hedgehog -> ProfileCardRes.drawable.card_back_orange + Giraffe -> ProfileCardRes.drawable.card_back_yellow + Flamingo -> ProfileCardRes.drawable.card_back_pink + Jellyfish -> ProfileCardRes.drawable.card_back_blue + None -> ProfileCardRes.drawable.card_back_white + } + Box( + modifier = modifier + .testTag(ProfileCardFlipCardBackTestTag) + .fillMaxSize() + .graphicsLayer { + rotationY = 180f + }, + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(background), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.size(160.dp), + ) + } +} + +@Composable +@Preview +fun FlipCardBackPreview() { + val uiState = ProfileCard.Exists.fake().toCardUiState()!! + val painter = + BitmapPainter(QRCode.ofSquares().build(uiState.link).renderToBytes().toImageBitmap()) + + KaigiTheme { + Surface( + modifier = Modifier + .size(300.dp, 380.dp) + .graphicsLayer { + rotationY = 180f + }, + ) { + FlipCardBack( + uiState = uiState, + painter = painter, + ) + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardFront.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardFront.kt new file mode 100644 index 000000000..e0391c4f1 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCardFront.kt @@ -0,0 +1,128 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.preat.peekaboo.image.picker.toImageBitmap +import conference_app_2024.feature.profilecard.generated.resources.card_front_blue +import conference_app_2024.feature.profilecard.generated.resources.card_front_green +import conference_app_2024.feature.profilecard.generated.resources.card_front_orange +import conference_app_2024.feature.profilecard.generated.resources.card_front_pink +import conference_app_2024.feature.profilecard.generated.resources.card_front_white +import conference_app_2024.feature.profilecard.generated.resources.card_front_yellow +import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.designsystem.theme.LocalProfileCardTheme +import io.github.droidkaigi.confsched.designsystem.theme.ProvideProfileCardTheme +import io.github.droidkaigi.confsched.model.ProfileCard +import io.github.droidkaigi.confsched.model.ProfileCardType +import io.github.droidkaigi.confsched.model.fake +import io.github.droidkaigi.confsched.profilecard.ProfileCardRes +import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card +import io.github.droidkaigi.confsched.profilecard.decodeBase64Bytes +import io.github.droidkaigi.confsched.profilecard.toCardUiState +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +const val ProfileCardFlipCardFrontTestTag = "ProfileCardFlipCardFrontTestTag" + +@Composable +internal fun FlipCardFront( + uiState: Card, + profileImagePainter: Painter, + modifier: Modifier = Modifier, +) { + val background = when (uiState.cardType) { + ProfileCardType.Iguana -> ProfileCardRes.drawable.card_front_green + ProfileCardType.Hedgehog -> ProfileCardRes.drawable.card_front_orange + ProfileCardType.Giraffe -> ProfileCardRes.drawable.card_front_yellow + ProfileCardType.Flamingo -> ProfileCardRes.drawable.card_front_pink + ProfileCardType.Jellyfish -> ProfileCardRes.drawable.card_front_blue + ProfileCardType.None -> ProfileCardRes.drawable.card_front_white + } + val namePrimaryColor = LocalProfileCardTheme.current.primaryColor + Box( + modifier = modifier + .testTag(ProfileCardFlipCardFrontTestTag) + .fillMaxSize(), + ) { + Image( + painter = painterResource(background), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(103.dp)) + Image( + painter = profileImagePainter, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(131.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = uiState.occupation, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + maxLines = 1, + ) + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle( + brush = Brush.verticalGradient(listOf(Color.White, namePrimaryColor)), + ), + ) { + append(uiState.nickname) + } + }, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + ) + } + } +} + +@Composable +@Preview +fun FlipCardFrontPreview() { + val uiState = ProfileCard.Exists.fake().toCardUiState()!! + val painter = BitmapPainter(uiState.image.decodeBase64Bytes().toImageBitmap()) + + KaigiTheme { + Surface(modifier = Modifier.size(300.dp, 380.dp)) { + ProvideProfileCardTheme(uiState.cardType.name) { + FlipCardFront( + uiState = uiState, + profileImagePainter = painter, + ) + } + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt new file mode 100644 index 000000000..30145ef36 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt @@ -0,0 +1,97 @@ +package io.github.droidkaigi.confsched.profilecard.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter +import io.github.droidkaigi.confsched.designsystem.theme.LocalProfileCardTheme +import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState + +@Composable +internal fun ShareableCard( + uiState: ProfileCardUiState.Card, + graphicsLayer: GraphicsLayer, + profileImagePainter: AsyncImagePainter, + qrCodeImagePainter: AsyncImagePainter, + onReadyShare: () -> Unit, +) { + var frontImage: ImageBitmap? by remember { mutableStateOf(null) } + var backImage: ImageBitmap? by remember { mutableStateOf(null) } + + LaunchedEffect(frontImage, backImage) { + if (frontImage != null && backImage != null) { + onReadyShare() + } + } + + BackgroundCapturableCardFront( + uiState = uiState, + profileImagePainter = profileImagePainter, + ) { + frontImage = it + } + + BackgroundCapturableCardBack( + uiState = uiState, + qrCodeImagePainter = qrCodeImagePainter, + ) { + backImage = it + } + + Box( + modifier = Modifier + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(graphicsLayer) + }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .background(LocalProfileCardTheme.current.primaryColor) + .padding(vertical = 50.dp), + ) { + backImage?.let { + Image( + bitmap = it, + contentDescription = null, + modifier = Modifier + .offset(x = 70.dp, y = 15.dp) + .rotate(10f) + .size(150.dp, 190.dp), + ) + } + frontImage?.let { + Image( + bitmap = it, + contentDescription = null, + modifier = Modifier + .offset(x = (-70).dp, y = (-15).dp) + .rotate(-10f) + .size(150.dp, 190.dp), + ) + } + } + } +}