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

Use UI state in profile edit screen #508

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.github.droidkaigi.confsched.model

import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

sealed interface ProfileCard {
data object Loading : ProfileCard

Expand All @@ -16,6 +19,33 @@ sealed interface ProfileCard {
}
}

data class ImageData internal constructor(
val image: String,
val imageBase64: ByteArray,
) {
private val imageHash: Int = imageBase64.contentHashCode()

constructor(image: String) : this(image, image.decodeBase64Bytes())
constructor(imageBase64: ByteArray) : this(imageBase64.toBase64(), imageBase64)

override fun equals(other: Any?): Boolean {
return this === other ||
other is ImageData && imageHash == other.imageHash
}

override fun hashCode(): Int {
return imageHash
}

companion object {
@OptIn(ExperimentalEncodingApi::class)
private fun ByteArray.toBase64(): String = Base64.encode(this)

@OptIn(ExperimentalEncodingApi::class)
private fun String.decodeBase64Bytes(): ByteArray = Base64.decode(this)
}
}

enum class ProfileCardTheme {
Iguana,
Hedgehog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults.indicatorLine
import androidx.compose.material3.TopAppBarDefaults
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand Down Expand Up @@ -89,6 +85,7 @@ import io.github.droidkaigi.confsched.compose.EventEmitter
import io.github.droidkaigi.confsched.compose.rememberEventEmitter
import io.github.droidkaigi.confsched.designsystem.theme.LocalProfileCardScreenTheme
import io.github.droidkaigi.confsched.designsystem.theme.ProvideProfileCardScreenTheme
import io.github.droidkaigi.confsched.model.ImageData
import io.github.droidkaigi.confsched.model.ProfileCard
import io.github.droidkaigi.confsched.model.ProfileCardTheme
import io.github.droidkaigi.confsched.profilecard.component.FlipCard
Expand All @@ -98,10 +95,8 @@ import io.github.droidkaigi.confsched.ui.UserMessageStateHolder
import io.github.droidkaigi.confsched.ui.component.AnimatedTextTopAppBar
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

const val profileCardScreenRoute = "profilecard"
const val profileCardScreenRoute = "profileCard"

const val ProfileCardEditScreenTestTag = "ProfileCardEditScreenTestTag"
const val ProfileCardEditScreenColumnTestTag = "ProfileCardEditScreenColumnTestTag"
Expand Down Expand Up @@ -134,9 +129,50 @@ internal sealed interface ProfileCardUiState {
val nickname: String = "",
val occupation: String = "",
val link: String = "",
val image: String? = null,
val imageData: ImageData? = null,
val theme: ProfileCardTheme = ProfileCardTheme.Iguana,
) : ProfileCardUiState
) : ProfileCardUiState {
val nicknameError @Composable get() = if (nickname.isEmpty()) {
stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.nickname),
)
} else {
""
}

val occupationError @Composable get() = if (occupation.isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The same basic code format is repeated here three times... it might be worth to make a generic version?

stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.occupation),
)
} else {
""
}

val linkError @Composable get() = if (link.isEmpty()) {
stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.link),
)
} else {
""
}

val imageError @Composable get() = if (imageData == null) {
stringResource(
ProfileCardRes.string.add_validate_format,
stringResource(ProfileCardRes.string.image),
)
} else {
""
}

val isValidInputs = nickname.isNotEmpty() &&
occupation.isNotEmpty() &&
link.isNotEmpty() &&
imageData != null
}

data class Card(
val nickname: String,
Expand Down Expand Up @@ -211,6 +247,9 @@ internal fun ProfileCardScreen(
ProfileCardUiType.Edit -> {
EditScreen(
uiState = uiState.editUiState,
onUpdateEditingState = {
eventEmitter.tryEmit(EditScreenEvent.Update(it))
},
onClickCreate = {
eventEmitter.tryEmit(EditScreenEvent.Create(it))
},
Expand Down Expand Up @@ -248,31 +287,13 @@ internal fun ProfileCardScreen(
@Composable
internal fun EditScreen(
uiState: ProfileCardUiState.Edit,
onUpdateEditingState: (ProfileCardUiState.Edit) -> Unit,
onClickCreate: (ProfileCard.Exists) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()

var nickname by remember { mutableStateOf(uiState.nickname) }
var occupation by remember { mutableStateOf(uiState.occupation) }
var link by remember { mutableStateOf(uiState.link) }
var imageByteArray: ByteArray? by remember { mutableStateOf(uiState.image?.decodeBase64Bytes()) }
val image by remember { derivedStateOf { imageByteArray?.toImageBitmap() } }
var selectedTheme by remember { mutableStateOf(uiState.theme) }

val (nicknameError, occupationError, linkError, imageError) = rememberValidationErrors(
nickname,
occupation,
link,
image,
)

val isValidInputs by remember {
derivedStateOf {
nickname.isNotEmpty() && occupation.isNotEmpty() && link.isNotEmpty() && image != null
}
}
val image = remember(uiState.imageData) { uiState.imageData?.imageBase64?.toImageBitmap() }

Scaffold(
modifier = modifier.testTag(ProfileCardEditScreenTestTag).padding(contentPadding),
Expand All @@ -296,27 +317,27 @@ internal fun EditScreen(
Text(stringResource(ProfileCardRes.string.profile_card_edit_description))

InputFieldWithError(
value = nickname,
value = uiState.nickname,
labelString = stringResource(ProfileCardRes.string.nickname),
errorMessage = nicknameError,
errorMessage = uiState.nicknameError,
textFieldTestTag = ProfileCardNicknameTextFieldTestTag,
onValueChange = { nickname = it },
onValueChange = { onUpdateEditingState(uiState.copy(nickname = it)) },
)
InputFieldWithError(
value = occupation,
value = uiState.occupation,
labelString = stringResource(ProfileCardRes.string.occupation),
errorMessage = occupationError,
errorMessage = uiState.occupationError,
textFieldTestTag = ProfileCardOccupationTextFieldTestTag,
onValueChange = { occupation = it },
onValueChange = { onUpdateEditingState(uiState.copy(occupation = it)) },
)
val linkLabel = stringResource(ProfileCardRes.string.link)
.plus(stringResource(ProfileCardRes.string.link_example_text))
InputFieldWithError(
value = link,
value = uiState.link,
labelString = linkLabel,
errorMessage = linkError,
errorMessage = uiState.linkError,
textFieldTestTag = ProfileCardLinkTextFieldTestTag,
onValueChange = { link = it },
onValueChange = { onUpdateEditingState(uiState.copy(link = it)) },
)

Column(
Expand All @@ -325,28 +346,31 @@ internal fun EditScreen(
Label(label = stringResource(ProfileCardRes.string.image))
ImagePickerWithError(
image = image,
onSelectedImage = { imageByteArray = it },
errorMessage = imageError,
onClearImage = { imageByteArray = null },
onSelectedImage = { onUpdateEditingState(uiState.copy(imageData = ImageData(it))) },
errorMessage = uiState.imageError,
onClearImage = { onUpdateEditingState(uiState.copy(imageData = null)) },
)

Text(stringResource(ProfileCardRes.string.select_theme))

ThemePiker(selectedTheme = selectedTheme, onClickImage = { selectedTheme = it })
ThemePiker(
selectedTheme = uiState.theme,
onClickImage = { onUpdateEditingState(uiState.copy(theme = it)) },
)

Button(
onClick = {
onClickCreate(
ProfileCard.Exists(
nickname = nickname,
occupation = occupation,
link = link,
image = imageByteArray?.toBase64() ?: "",
nickname = uiState.nickname,
occupation = uiState.occupation,
link = uiState.link,
image = uiState.imageData?.image ?: "",
theme = uiState.theme,
),
)
},
enabled = isValidInputs,
enabled = uiState.isValidInputs,
modifier = Modifier.fillMaxWidth()
.testTag(ProfileCardCreateButtonTestTag),
) {
Expand All @@ -360,12 +384,6 @@ internal fun EditScreen(
}
}

@OptIn(ExperimentalEncodingApi::class)
private fun ByteArray.toBase64(): String = Base64.encode(this)

@OptIn(ExperimentalEncodingApi::class)
private fun String.decodeBase64Bytes(): ByteArray = Base64.decode(this)

@Composable
internal fun Label(label: String) {
Text(
Expand All @@ -375,39 +393,6 @@ internal fun Label(label: String) {
)
}

@Composable
private fun rememberValidationErrors(
nickname: String,
occupation: String,
link: String,
image: ImageBitmap?,
): List<String> {
val nicknameValidationErrorString = stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.nickname),
)
val occupationValidationErrorString = stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.occupation),
)
val linkValidationErrorString = stringResource(
ProfileCardRes.string.enter_validate_format,
stringResource(ProfileCardRes.string.link),
)
val imageValidationErrorString = stringResource(
ProfileCardRes.string.add_validate_format,
stringResource(ProfileCardRes.string.image),
)

return remember(nickname, occupation, link, image) {
val nicknameError = if (nickname.isEmpty()) nicknameValidationErrorString else ""
val occupationError = if (occupation.isEmpty()) occupationValidationErrorString else ""
val linkError = if (link.isEmpty()) linkValidationErrorString else ""
val imageError = if (image == null) imageValidationErrorString else ""
listOf(nicknameError, occupationError, linkError, imageError)
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InputFieldWithError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import io.github.droidkaigi.confsched.compose.SafeLaunchedEffect
import io.github.droidkaigi.confsched.model.ImageData
import io.github.droidkaigi.confsched.model.ProfileCard
import io.github.droidkaigi.confsched.model.ProfileCardRepository
import io.github.droidkaigi.confsched.model.localProfileCardRepository
import io.github.droidkaigi.confsched.ui.providePresenterDefaults
import io.github.takahirom.rin.rememberRetained
import kotlinx.coroutines.flow.Flow

internal sealed interface ProfileCardScreenEvent

internal sealed interface EditScreenEvent : ProfileCardScreenEvent {
data object SelectImage : EditScreenEvent
data class Update(val editUiState: ProfileCardUiState.Edit) : EditScreenEvent
data class Create(val profileCard: ProfileCard.Exists) : EditScreenEvent
}

Expand All @@ -31,7 +34,7 @@ private fun ProfileCard.toEditUiState(): ProfileCardUiState.Edit {
nickname = nickname,
occupation = occupation,
link = link,
image = image,
imageData = image?.run(::ImageData),
theme = theme,
)
ProfileCard.DoesNotExists, ProfileCard.Loading -> ProfileCardUiState.Edit()
Expand All @@ -58,7 +61,8 @@ internal fun profileCardScreenPresenter(
): ProfileCardScreenState = providePresenterDefaults { userMessageStateHolder ->
val profileCard: ProfileCard by rememberUpdatedState(repository.profileCard())
var isLoading: Boolean by remember { mutableStateOf(false) }
val editUiState: ProfileCardUiState.Edit by rememberUpdatedState(profileCard.toEditUiState())
var editingUiState: ProfileCardUiState.Edit? by rememberRetained { mutableStateOf(null) }
val editUiState: ProfileCardUiState.Edit by rememberUpdatedState(editingUiState ?: profileCard.toEditUiState())
val cardUiState: ProfileCardUiState.Card? by rememberUpdatedState(profileCard.toCardUiState())
var uiType: ProfileCardUiType by remember { mutableStateOf(ProfileCardUiType.Loading) }

Expand All @@ -84,6 +88,10 @@ internal fun profileCardScreenPresenter(
userMessageStateHolder.showMessage("Share Profile Card")
}

is EditScreenEvent.Update -> {
editingUiState = it.editUiState
}

is EditScreenEvent.Create -> {
userMessageStateHolder.showMessage("Create Profile Card")
repository.save(it.profileCard)
Expand Down
Loading