Skip to content

Commit

Permalink
✨ Add edit profile decompose component and navigation
Browse files Browse the repository at this point in the history
Add edit profile component with MVI pattern, including store, model, and component classes. Update navigation to handle edit profile flow with proper state management.
  • Loading branch information
CXwudi committed Dec 22, 2024
1 parent 453ce95 commit 1d423ec
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mikufan.cx.conduit.frontend.ui.screen.main.me

import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
Expand All @@ -20,6 +21,7 @@ fun MeNavPage(meNavComponent: MeNavComponent, modifier: Modifier = Modifier) {
content = { child ->
when (child) {
is MeNavComponentChild.MePage -> MePage(child.mePageComponent)
is MeNavComponentChild.EditProfile -> Text("TODO: Edit Profile")
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import mikufan.cx.conduit.frontend.logic.component.main.MainNavComponentFactory
import mikufan.cx.conduit.frontend.logic.component.main.MainNavStoreFactory
import mikufan.cx.conduit.frontend.logic.component.main.auth.AuthPageComponentFactory
import mikufan.cx.conduit.frontend.logic.component.main.auth.AuthPageStoreFactory
import mikufan.cx.conduit.frontend.logic.component.main.me.EditProfileComponentFactory
import mikufan.cx.conduit.frontend.logic.component.main.me.EditProfileStoreFactory
import mikufan.cx.conduit.frontend.logic.component.main.me.MeNavComponentFactory
import mikufan.cx.conduit.frontend.logic.component.main.me.MePageComponentFactory
import mikufan.cx.conduit.frontend.logic.component.main.me.MeStoreFactory
Expand All @@ -26,6 +28,7 @@ val storeModule = module {
single { MainNavStoreFactory(get(), get()) }
single { AuthPageStoreFactory(get(), get()) }
single { MeStoreFactory(get(), get()) }
single { EditProfileStoreFactory(get()) }
}

/**
Expand All @@ -38,6 +41,7 @@ val componentFactoryModule = module {
singleOf(::AuthPageComponentFactory)
singleOf(::MeNavComponentFactory)
singleOf(::MePageComponentFactory)
singleOf(::EditProfileComponentFactory)
}

val decomposeViewModelModules = listOf(storeModule, componentFactoryModule)
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package mikufan.cx.conduit.frontend.logic.component.main.me

import com.arkivanov.decompose.ComponentContext
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
import com.arkivanov.mvikotlin.core.instancekeeper.getStore
import com.arkivanov.mvikotlin.extensions.coroutines.labels
import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import mikufan.cx.conduit.frontend.logic.component.util.MviComponent

interface EditProfileComponent : MviComponent<EditProfileIntent, EditProfileState>

class DefaultEditProfileComponent(
initialState: EditProfileState,
componentContext: ComponentContext,
editProfileStoreFactory: EditProfileStoreFactory,
private val onSaveSuccess: () -> Unit,
) : EditProfileComponent, ComponentContext by componentContext {

private val store = instanceKeeper.getStore { editProfileStoreFactory.createStore(initialState) }

override val state: StateFlow<EditProfileState> = store.stateFlow(coroutineScope())

override fun send(intent: EditProfileIntent) = store.accept(intent)

init {
coroutineScope().launch {
store.labels.collect {
when (it) {
is EditProfileLabel.SaveSuccessLabel -> onSaveSuccess()
is EditProfileLabel.Unit -> Unit // do nothing as this label is just for test purpose
}
}
}
}

}

class EditProfileComponentFactory(
private val editProfileStoreFactory: EditProfileStoreFactory,
) {
fun create(
componentContext: ComponentContext,
loadedMe: LoadedMe,
onSaveSuccess: () -> Unit,
): EditProfileComponent = DefaultEditProfileComponent(
initialState = EditProfileState(
email = loadedMe.email,
username = loadedMe.username,
bio = loadedMe.bio,
imageUrl = loadedMe.imageUrl,
),
componentContext = componentContext,
editProfileStoreFactory = editProfileStoreFactory,
onSaveSuccess = onSaveSuccess,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package mikufan.cx.conduit.frontend.logic.component.main.me

data class EditProfileState(
val email: String,
val username: String,
val bio: String,
val imageUrl: String,
/**
* use null to indicate no change to password
*/
val password: String? = null,
) {
init {
if (password != null) {
require(password.isNotEmpty()) { "Password cannot be empty" }
}
}
}

sealed interface EditProfileIntent {
data class EmailChanged(val email: String) : EditProfileIntent
data class UsernameChanged(val username: String) : EditProfileIntent
data class BioChanged(val bio: String) : EditProfileIntent
data class ImageUrlChanged(val imageUrl: String) : EditProfileIntent
data class PasswordChanged(val password: String) : EditProfileIntent
data object Save : EditProfileIntent
}

sealed interface EditProfileLabel {
data object SaveSuccessLabel : EditProfileLabel
/**
* Just for test purpose
*/
data object Unit : EditProfileLabel
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package mikufan.cx.conduit.frontend.logic.component.main.me

import com.arkivanov.mvikotlin.core.store.Reducer
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.coroutineExecutorFactory
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

class EditProfileStoreFactory(
private val storeFactory: StoreFactory,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {

private val executorFactory = coroutineExecutorFactory<EditProfileIntent, Nothing, EditProfileState, Msg, EditProfileLabel>(mainDispatcher) {
onIntent<EditProfileIntent.EmailChanged> {
dispatch(Msg.EmailChanged(it.email))
}

onIntent<EditProfileIntent.UsernameChanged> {
dispatch(Msg.UsernameChanged(it.username))
}

onIntent<EditProfileIntent.BioChanged> {
dispatch(Msg.BioChanged(it.bio))
}

onIntent<EditProfileIntent.ImageUrlChanged> {
dispatch(Msg.ImageUrlChanged(it.imageUrl))
}

onIntent<EditProfileIntent.PasswordChanged> {
dispatch(Msg.PasswordChanged(it.password))
}

onIntent<EditProfileIntent.Save> {
TODO("Send put request and check result")
}
}

private val reducer: Reducer<EditProfileState, Msg> = Reducer { msg ->
when (msg) {
is Msg.EmailChanged -> this.copy(email = msg.email)
is Msg.UsernameChanged -> this.copy(username = msg.username)
is Msg.BioChanged -> this.copy(bio = msg.bio)
is Msg.ImageUrlChanged -> this.copy(imageUrl = msg.imageUrl)
is Msg.PasswordChanged -> this.copy(password = msg.password)
}
}

fun createStore(initialState: EditProfileState) = storeFactory.create(
name = "EditProfileStore",
initialState = initialState,
executorFactory = executorFactory,
reducer = reducer
)

private sealed interface Msg {
data class EmailChanged(val email: String) : Msg
data class UsernameChanged(val username: String) : Msg
data class BioChanged(val bio: String) : Msg
data class ImageUrlChanged(val imageUrl: String) : Msg
data class PasswordChanged(val password: String) : Msg
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ interface MePageComponent : MviComponent<MePageIntent, MePageState>
class DefaultMePageComponent(
componentContext: ComponentContext,
meStoreFactory: MeStoreFactory,
onEditProfile: () -> Unit,
onAddArticle: () -> Unit,
private val onEditProfile: (LoadedMe) -> Unit,
private val onAddArticle: () -> Unit,
) : MePageComponent, ComponentContext by componentContext {

private val navigationToActionMap: Map<MePageIntent, () -> Unit> = mapOf(
MePageIntent.EditProfile to onEditProfile,
MePageIntent.AddArticle to onAddArticle,
)

private val store = instanceKeeper.getStore { meStoreFactory.createStore() }

override val state: StateFlow<MePageState> = store.stateFlow(coroutineScope())

override fun send(intent: MePageIntent) {
if (intent in navigationToActionMap.keys) {
navigationToActionMap[intent]!!.invoke()
} else {
store.accept(intent)
when (intent) {
is MePageIntent.EditProfile -> {
val stateValue = state.value
if (stateValue is MePageState.Loaded) {
onEditProfile(LoadedMe(stateValue.email, stateValue.username, stateValue.bio, stateValue.imageUrl))
}
}
is MePageIntent.AddArticle -> onAddArticle()
else -> store.accept(intent)
}
}
}
Expand All @@ -39,7 +39,7 @@ class MePageComponentFactory(
) {
fun create(
componentContext: ComponentContext,
onEditProfile: () -> Unit,
onEditProfile: (LoadedMe) -> Unit,
onAddArticle: () -> Unit,
) =
DefaultMePageComponent(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package mikufan.cx.conduit.frontend.logic.component.main.me

import kotlinx.serialization.Serializable

sealed interface MePageState {
data object Loading : MePageState
data class Error(val errorMsg: String) : MePageState
Expand All @@ -14,6 +16,7 @@ sealed interface MePageState {
/**
* Used for passing the information from service to store
*/
@Serializable
class LoadedMe(
val email: String,
val username: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.value.Value
import kotlinx.serialization.Serializable
Expand All @@ -15,6 +16,7 @@ interface MeNavComponent {
class DefaultMeNavComponent(
componentContext: ComponentContext,
private val mePageComponentFactory: MePageComponentFactory,
private val editProfileComponentFactory: EditProfileComponentFactory,
) : MeNavComponent, ComponentContext by componentContext {

private val stackNavigation = StackNavigation<Config>()
Expand All @@ -33,13 +35,18 @@ class DefaultMeNavComponent(
Config.MePage -> {
val mePageComponent = mePageComponentFactory.create(
componentContext = componentContext,
onEditProfile = { stackNavigation.pushNew(Config.EditProfile) },
onEditProfile = { loadedMe -> stackNavigation.pushNew(Config.EditProfile(loadedMe)) },
onAddArticle = { stackNavigation.pushNew(Config.AddArticle) },
)
return MeNavComponentChild.MePage(mePageComponent)
}
Config.EditProfile -> {
TODO("Not yet implemented")
is Config.EditProfile -> {
val editProfileComponent = editProfileComponentFactory.create(
componentContext = componentContext,
loadedMe = config.loadedMe,
onSaveSuccess = { stackNavigation.pop() },
)
return MeNavComponentChild.EditProfile(editProfileComponent)
}
Config.AddArticle -> {
TODO("Not yet implemented")
Expand All @@ -53,7 +60,7 @@ class DefaultMeNavComponent(
data object MePage : Config

@Serializable
data object EditProfile : Config
data class EditProfile(val loadedMe: LoadedMe) : Config

@Serializable
data object AddArticle : Config
Expand All @@ -62,10 +69,12 @@ class DefaultMeNavComponent(

class MeNavComponentFactory(
private val mePageComponentFactory: MePageComponentFactory,
private val editProfileComponentFactory: EditProfileComponentFactory,
) {
fun create(componentContext: ComponentContext) = DefaultMeNavComponent(
componentContext = componentContext,
mePageComponentFactory = mePageComponentFactory,
editProfileComponentFactory = editProfileComponentFactory,
)
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ package mikufan.cx.conduit.frontend.logic.component.main.me

sealed interface MeNavComponentChild {
data class MePage(val mePageComponent: MePageComponent) : MeNavComponentChild
data class EditProfile(val editProfileComponent: EditProfileComponent) : MeNavComponentChild
}

0 comments on commit 1d423ec

Please sign in to comment.