diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index 2b7455ba2..c39d6506a 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(projects.feature.contributors) implementation(projects.feature.sessions) implementation(projects.feature.eventmap) + implementation(projects.feature.profilecard) implementation(projects.core.model) implementation(projects.core.data) implementation(projects.core.designsystem) diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt index 58a4efabe..4491ff040 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt @@ -47,6 +47,9 @@ import io.github.droidkaigi.confsched.sessions.sessionScreens import io.github.droidkaigi.confsched.sessions.timetableScreenRoute import io.github.droidkaigi.confsched.share.ShareNavigator import io.github.droidkaigi.confsched.ui.NavHostWithSharedAxisX +import io.github.droidkaigi.confshed.profilecard.navigateProfileCardScreen +import io.github.droidkaigi.confshed.profilecard.profileCardScreen +import io.github.droidkaigi.confshed.profilecard.profileCardScreenRoute import kotlinx.collections.immutable.PersistentList @Composable @@ -115,6 +118,7 @@ private fun NavGraphBuilder.mainScreen( onNavigationIconClick = navController::popBackStack, onEventMapItemClick = externalNavController::navigate, ) + profileCardScreen(contentPadding) }, ) } @@ -125,6 +129,7 @@ class KaigiAppMainNestedGraphStateHolder : MainNestedGraphStateHolder { override fun routeToTab(route: String): MainScreenTab? { return when (route) { timetableScreenRoute -> Timetable + profileCardScreenRoute -> ProfileCard else -> null } } @@ -137,7 +142,7 @@ class KaigiAppMainNestedGraphStateHolder : MainNestedGraphStateHolder { Timetable -> mainNestedNavController.navigateTimetableScreen() EventMap -> mainNestedNavController.navigateEventMapScreen() About -> TODO() - ProfileCard -> TODO() + ProfileCard -> mainNestedNavController.navigateProfileCardScreen() } } } diff --git a/app-ios-shared/build.gradle.kts b/app-ios-shared/build.gradle.kts index f23a4a5ea..31e3e8546 100644 --- a/app-ios-shared/build.gradle.kts +++ b/app-ios-shared/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { api(projects.feature.sessions) api(projects.feature.eventmap) api(projects.feature.contributors) + api(projects.feature.profilecard) implementation(libs.kotlinxCoroutinesCore) implementation(libs.skieAnnotation) } diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCard.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCard.kt new file mode 100644 index 000000000..34c3e6635 --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCard.kt @@ -0,0 +1,9 @@ +package io.github.droidkaigi.confsched.model + +data class ProfileCard( + val nickname: String, + val occupation: String?, + val link: String?, + val imageUri: String?, + val theme: ProfileCardTheme, +) diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardTheme.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardTheme.kt new file mode 100644 index 000000000..83a28cc1c --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/ProfileCardTheme.kt @@ -0,0 +1,5 @@ +package io.github.droidkaigi.confsched.model + +enum class ProfileCardTheme { + Default, +} diff --git a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt index a84b9e860..b81cec9f3 100644 --- a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt +++ b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/MainScreen.kt @@ -137,19 +137,20 @@ enum class MainScreenTab( contentDescription = MainStrings.EventMap.asString(), ), - @OptIn(ExperimentalResourceApi::class) - ProfileCard( - icon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_outline), - selectedIcon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_fill), - label = MainStrings.Achievements.asString(), - contentDescription = MainStrings.Achievements.asString(), - ), About( icon = IconRepresentation.Vector(Icons.Outlined.Info), selectedIcon = IconRepresentation.Vector(Icons.Filled.Info), label = MainStrings.About.asString(), contentDescription = MainStrings.About.asString(), ), + + @OptIn(ExperimentalResourceApi::class) + ProfileCard( + icon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_outline), + selectedIcon = IconRepresentation.Drawable(drawableId = Res.drawable.icon_achievement_fill), + label = MainStrings.ProfileCard.asString(), + contentDescription = MainStrings.ProfileCard.asString(), + ), } data class MainScreenUiState( diff --git a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/strings/MainStrings.kt b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/strings/MainStrings.kt index eadd34847..99353bef3 100644 --- a/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/strings/MainStrings.kt +++ b/feature/main/src/commonMain/kotlin/io/github/droidkaigi/confsched/main/strings/MainStrings.kt @@ -7,7 +7,7 @@ import io.github.droidkaigi.confsched.designsystem.strings.StringsBindings sealed class MainStrings : Strings(Bindings) { data object Timetable : MainStrings() data object EventMap : MainStrings() - data object Achievements : MainStrings() + data object ProfileCard : MainStrings() data object About : MainStrings() data object Contributors : MainStrings() class Time(val hours: Int, val minutes: Int) : MainStrings() @@ -17,7 +17,7 @@ sealed class MainStrings : Strings(Bindings) { when (item) { Timetable -> "Timetable" EventMap -> "Event Map" - Achievements -> "Achievements" + ProfileCard -> "Profile Card" About -> "About" Contributors -> "Contributors" is Time -> "${item.hours}時${item.minutes}分" @@ -27,7 +27,7 @@ sealed class MainStrings : Strings(Bindings) { when (item) { Timetable -> "Timetable" EventMap -> "Event Map" - Achievements -> "Achievements" + ProfileCard -> "Profile Card" About -> "About" Contributors -> "Contributors" is Time -> "${item.hours}:${item.minutes}" diff --git a/feature/profilecard/.gitignore b/feature/profilecard/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/profilecard/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/profilecard/build.gradle.kts b/feature/profilecard/build.gradle.kts new file mode 100644 index 000000000..4d973783b --- /dev/null +++ b/feature/profilecard/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("droidkaigi.convention.kmpfeature") +} + +android.namespace = "io.github.droidkaigi.confsched.feature.profilecard" +kotlin { + sourceSets { + commonMain { + dependencies { + implementation(projects.core.designsystem) + implementation(projects.core.ui) + implementation(projects.core.model) + + implementation(libs.composeNavigation) + implementation(compose.materialIconsExtended) + } + } + androidTarget { + dependencies { + implementation(libs.composeMaterialWindowSize) + } + } + androidUnitTest { + dependencies { + implementation(projects.core.testing) + } + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt new file mode 100644 index 000000000..cd29fec7e --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreen.kt @@ -0,0 +1,238 @@ +package io.github.droidkaigi.confshed.profilecard + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.platform.LocalLayoutDirection +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import io.github.droidkaigi.confsched.compose.EventEmitter +import io.github.droidkaigi.confsched.compose.rememberEventEmitter +import io.github.droidkaigi.confsched.model.ProfileCard +import io.github.droidkaigi.confsched.model.ProfileCardTheme +import io.github.droidkaigi.confsched.ui.SnackbarMessageEffect +import io.github.droidkaigi.confsched.ui.UserMessageStateHolder +import io.github.droidkaigi.confshed.profilecard.ProfileCardUiState.Edit + +const val profileCardScreenRoute = "profilecard" +internal const val ProfileCardScreenTestTag = "ProfileCardTestTag" + +fun NavGraphBuilder.profileCardScreen( + contentPadding: PaddingValues, +) { + composable(profileCardScreenRoute) { + ProfileCardScreen(contentPadding) + } +} + +fun NavController.navigateProfileCardScreen() { + navigate(profileCardScreenRoute) { + popUpTo(checkNotNull(graph.findStartDestination().route)) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +internal sealed interface ProfileCardUiState { + data class Edit( + val nickname: String, + val occupation: String?, + val link: String?, + val imageUri: String?, + val theme: ProfileCardTheme, + ) : ProfileCardUiState { + companion object { + fun initial() = Edit( + nickname = "", + occupation = null, + link = null, + imageUri = null, + theme = ProfileCardTheme.Default, + ) + } + } + + data class Card( + val nickname: String, + val occupation: String?, + val link: String?, + val imageUri: String?, + val theme: ProfileCardTheme, + ) : ProfileCardUiState +} + +internal data class ProfileCardScreenUiState( + val isLoading: Boolean, + val contentUiState: ProfileCardUiState, + val userMessageStateHolder: UserMessageStateHolder, +) + +internal fun ProfileCard.toUiState() = + ProfileCardUiState.Card( + nickname = nickname, + occupation = occupation, + link = link, + imageUri = imageUri, + theme = theme, + ) + +@Composable +fun ProfileCardScreen( + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + ProfileCardScreen( + contentPadding = contentPadding, + modifier = modifier, + rememberEventEmitter(), + ) +} + +@Composable +internal fun ProfileCardScreen( + contentPadding: PaddingValues, + modifier: Modifier = Modifier, + eventEmitter: EventEmitter = rememberEventEmitter(), + uiState: ProfileCardScreenUiState = profileCardScreenPresenter(eventEmitter), +) { + val snackbarHostState = remember { SnackbarHostState() } + val layoutDirection = LocalLayoutDirection.current + + SnackbarMessageEffect( + snackbarHostState = snackbarHostState, + userMessageStateHolder = uiState.userMessageStateHolder, + ) + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + contentWindowInsets = WindowInsets( + left = contentPadding.calculateLeftPadding(layoutDirection), + top = contentPadding.calculateTopPadding(), + right = contentPadding.calculateRightPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ), + ) { padding -> + when (val contentUiState = uiState.contentUiState) { + is ProfileCardUiState.Edit -> { + EditScreen( + uiState = contentUiState, + onClickCreate = { + eventEmitter.tryEmit(EditScreenEvent.CreateProfileCard(it)) + }, + contentPadding = padding, + ) + } + + is ProfileCardUiState.Card -> { + CardScreen( + uiState = contentUiState, + onClickReset = { + eventEmitter.tryEmit(CardScreenEvent.Reset) + }, + contentPadding = padding, + ) + } + } + if (uiState.isLoading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(padding).fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +internal fun EditScreen( + uiState: Edit, + onClickCreate: (ProfileCard) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + var nickname by remember { mutableStateOf(uiState.nickname) } + var occupation by remember { mutableStateOf(uiState.occupation) } + var link by remember { mutableStateOf(uiState.link) } + var imageUri by remember { mutableStateOf(uiState.imageUri) } + + Column( + modifier = modifier.padding(contentPadding), + ) { + Text("ProfileCardEdit") + TextField( + value = nickname, + onValueChange = { nickname = it }, + placeholder = { Text("Nickname") }, + ) + TextField( + value = occupation ?: "", + onValueChange = { occupation = it }, + placeholder = { Text("Occupation") }, + ) + TextField( + value = link ?: "", + onValueChange = { link = it }, + placeholder = { Text("Link") }, + ) + Button({ + onClickCreate( + ProfileCard( + nickname = nickname, + occupation = occupation, + link = link, + imageUri = imageUri, + theme = uiState.theme, + ), + ) + }) { + Text("Create") + } + } +} + +@Composable +internal fun CardScreen( + uiState: ProfileCardUiState.Card, + onClickReset: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + Column( + modifier = modifier.padding(contentPadding), + ) { + Text("ProfileCard") + Text(uiState.nickname) + if (uiState.occupation != null) { + Text(uiState.occupation) + } + if (uiState.link != null) { + Text(uiState.link) + } + Button(onClickReset) { + Text("Reset") + } + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreenPresenter.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreenPresenter.kt new file mode 100644 index 000000000..25afd8e08 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confshed/profilecard/ProfileCardScreenPresenter.kt @@ -0,0 +1,70 @@ +package io.github.droidkaigi.confshed.profilecard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.github.droidkaigi.confsched.compose.SafeLaunchedEffect +import io.github.droidkaigi.confsched.model.ProfileCard +import io.github.droidkaigi.confsched.ui.providePresenterDefaults +import kotlinx.coroutines.flow.Flow + +internal sealed interface ProfileCardScreenEvent + +internal sealed interface EditScreenEvent : ProfileCardScreenEvent { + data object SelectImage : EditScreenEvent + data class CreateProfileCard(val profileCard: ProfileCard) : EditScreenEvent +} + +internal sealed interface CardScreenEvent : ProfileCardScreenEvent { + data object ShareProfileCard : CardScreenEvent + data object Reset : CardScreenEvent +} + +@Composable +internal fun profileCardScreenPresenter( + events: Flow, +): ProfileCardScreenUiState = providePresenterDefaults { userMessageStateHolder -> + // TODO: get from repository + val profileCard: ProfileCard? by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var contentUiState by remember { + mutableStateOf( + profileCard?.toUiState() ?: ProfileCardUiState.Edit.initial(), + ) + } + + SafeLaunchedEffect(Unit) { + events.collect { + isLoading = true + when (it) { + CardScreenEvent.Reset -> { + userMessageStateHolder.showMessage("Reset") + contentUiState = ProfileCardUiState.Edit.initial() + } + + CardScreenEvent.ShareProfileCard -> { + userMessageStateHolder.showMessage("Share Profile Card") + } + + is EditScreenEvent.CreateProfileCard -> { + userMessageStateHolder.showMessage("Create Profile Card") + // TODO: save model by repository + contentUiState = it.profileCard.toUiState() + } + + EditScreenEvent.SelectImage -> { + userMessageStateHolder.showMessage("Select Image") + } + } + isLoading = false + } + } + + ProfileCardScreenUiState( + isLoading = isLoading, + contentUiState = contentUiState, + userMessageStateHolder = userMessageStateHolder, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index dc1950673..fb8a7d35f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include( ":feature:sessions", ":feature:contributors", ":feature:eventmap", + ":feature:profilecard", ":core:designsystem", ":core:data", ":core:model",