diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a542a30..6e42fc6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,14 +68,13 @@ android { dependencies { implementation(project(":feature:home")) implementation(project(":feature:photo")) - - // TODO Wei -// implementation(project(":feature:contactme")) + implementation(project(":feature:contactme")) implementation(project(":core:designsystem")) implementation(project(":core:common")) implementation(project(":core:data")) implementation(project(":core:model")) + // TODO Wei // implementation(project(":core:datastore")) androidTestImplementation(project(":core:designsystem")) diff --git a/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt index 78790d4..d9dd5de 100644 --- a/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt +++ b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.window.layout.DisplayFeature import com.wei.picquest.core.designsystem.ui.DeviceOrientation +import com.wei.picquest.feature.contactme.contactme.navigation.contactMeGraph import com.wei.picquest.feature.home.home.navigation.homeGraph import com.wei.picquest.feature.home.home.navigation.homeRoute import com.wei.picquest.feature.photo.photolibrary.navigation.photoLibraryGraph @@ -44,5 +45,12 @@ fun PqNavHost( photoLibraryGraph(navController = navController) }, ) + contactMeGraph( + navController = navController, + contentType = contentType, + displayFeatures = displayFeatures, + navigationType = navigationType, + nestedGraphs = { }, + ) } } diff --git a/app/src/main/java/com/wei/picquest/ui/PqAppState.kt b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt index 5eed23d..7928336 100644 --- a/app/src/main/java/com/wei/picquest/ui/PqAppState.kt +++ b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt @@ -25,6 +25,8 @@ import com.wei.picquest.core.designsystem.ui.PqNavigationType import com.wei.picquest.core.designsystem.ui.currentDeviceOrientation import com.wei.picquest.core.designsystem.ui.isBookPosture import com.wei.picquest.core.designsystem.ui.isSeparating +import com.wei.picquest.feature.contactme.contactme.navigation.contactMeRoute +import com.wei.picquest.feature.contactme.contactme.navigation.navigateToContactMe import com.wei.picquest.feature.home.home.navigation.homeRoute import com.wei.picquest.feature.home.home.navigation.navigateToHome import com.wei.picquest.feature.photo.photosearch.navigation.navigateToPhotoSearch @@ -152,6 +154,7 @@ class PqAppState( @Composable get() = when (currentDestination?.route) { homeRoute -> TopLevelDestination.HOME photoSearchRoute -> TopLevelDestination.PHOTO + contactMeRoute -> TopLevelDestination.CONTACT_ME else -> null } @@ -203,9 +206,9 @@ class PqAppState( topLevelNavOptions, ) -// TopLevelDestination.CONTACT_ME -> navController.navigateToContactMe( -// topLevelNavOptions, -// ) + TopLevelDestination.CONTACT_ME -> navController.navigateToContactMe( + topLevelNavOptions, + ) else -> showFunctionalityNotAvailablePopup.value = true } diff --git a/feature/contactme/.gitignore b/feature/contactme/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/contactme/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/contactme/build.gradle.kts b/feature/contactme/build.gradle.kts new file mode 100644 index 0000000..bcf3613 --- /dev/null +++ b/feature/contactme/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.pq.android.feature) + alias(libs.plugins.pq.android.library.compose) + alias(libs.plugins.pq.android.hilt) +} + +android { + namespace = "com.wei.picquest.feature.contactme" +} + +dependencies { +} \ No newline at end of file diff --git a/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenRobot.kt b/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenRobot.kt new file mode 100644 index 0000000..1a23cab --- /dev/null +++ b/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenRobot.kt @@ -0,0 +1,206 @@ +package com.wei.picquest.feature.contactme.contactme + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.window.layout.DisplayFeature +import com.google.common.truth.Truth +import com.wei.picquest.core.designsystem.theme.PqTheme +import com.wei.picquest.core.designsystem.ui.PqContentType +import com.wei.picquest.feature.contactme.R +import com.wei.picquest.feature.contactme.contactme.utilities.EMAIL +import com.wei.picquest.feature.contactme.contactme.utilities.LINKEDIN_URL +import com.wei.picquest.feature.contactme.contactme.utilities.NAME_ENG +import com.wei.picquest.feature.contactme.contactme.utilities.NAME_TW +import com.wei.picquest.feature.contactme.contactme.utilities.PHONE +import com.wei.picquest.feature.contactme.contactme.utilities.POSITION +import com.wei.picquest.feature.contactme.contactme.utilities.TIME_ZONE +import kotlin.properties.ReadOnlyProperty + +/** + * Screen Robot for [ContactMeScreenTest]. + * + * 遵循此模型,找到測試使用者介面元素、檢查其屬性、和透過測試規則執行動作: + * composeTestRule{.finder}{.assertion}{.action} + * + * Testing cheatsheet: + * https://developer.android.com/jetpack/compose/testing-cheatsheet + */ +internal fun contactMeScreenRobot( + composeTestRule: AndroidComposeTestRule, ComponentActivity>, + func: ContactMeScreenRobot.() -> Unit, +) = ContactMeScreenRobot(composeTestRule).apply(func) + +internal open class ContactMeScreenRobot( + private val composeTestRule: AndroidComposeTestRule, ComponentActivity>, +) { + private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = + ReadOnlyProperty { _, _ -> activity.getString(resId) } + + private val profilePictureDescription by composeTestRule.stringResource(R.string.profile_picture) + private val linkedinString by composeTestRule.stringResource(R.string.linkedin) + private val emailString by composeTestRule.stringResource(R.string.email) + private val timezoneString by composeTestRule.stringResource(R.string.timezone) + private val callDescription by composeTestRule.stringResource(R.string.call) + + private val profilePicture by lazy { + composeTestRule.onNodeWithContentDescription( + profilePictureDescription.format(testUiState.nameEng), + useUnmergedTree = true, + ) + } + private val name by lazy { + composeTestRule.onNodeWithContentDescription( + testUiState.nameEng, + useUnmergedTree = true, + ) + } + private val position by lazy { + composeTestRule.onNodeWithContentDescription( + testUiState.position, + useUnmergedTree = true, + ) + } + private val linkedin by lazy { + composeTestRule.onNodeWithContentDescription( + linkedinString, + useUnmergedTree = true, + ) + } + private val linkedinValue by lazy { + composeTestRule.onNodeWithContentDescription( + testUiState.linkedinUrl, + useUnmergedTree = true, + ) + } + private val email by lazy { + composeTestRule.onNodeWithContentDescription( + emailString, + useUnmergedTree = true, + ) + } + private val emailValue by lazy { + composeTestRule.onNodeWithContentDescription( + testUiState.email, + useUnmergedTree = true, + ) + } + private val timeZone by lazy { + composeTestRule.onNodeWithContentDescription( + timezoneString, + useUnmergedTree = true, + ) + } + private val timeZoneValue by lazy { + composeTestRule.onNodeWithContentDescription( + testUiState.timeZone, + useUnmergedTree = true, + ) + } + private val call by lazy { + composeTestRule.onNodeWithContentDescription( + callDescription.format(testUiState.nameEng), + useUnmergedTree = true, + ) + } + + private var callClicked: Boolean = false + + fun setContactMeScreenContent( + contentType: PqContentType, + displayFeature: List, + ) { + composeTestRule.setContent { + PqTheme { + ContactMeScreen( + uiStates = testUiState, + contentType = contentType, + displayFeatures = displayFeature, + onPhoneClick = { callClicked = true }, + ) + } + } + } + + fun verifyProfilePictureDisplayed() { + profilePicture.assertExists().assertIsDisplayed() + } + + fun verifyNameDisplayed() { + name.assertExists().assertIsDisplayed() + } + + fun verifyPositionDisplayed() { + position.assertExists().assertIsDisplayed() + } + + fun verifyCallDisplayed() { + call.assertExists().assertIsDisplayed() + } + + fun verifyLinkedinExists() { + linkedin.assertExists() + } + + fun verifyLinkedinValueExists() { + linkedinValue.assertExists() + } + + fun verifyEmailExists() { + email.assertExists() + } + + fun verifyEmailValueExists() { + emailValue.assertExists() + } + + fun verifyTimeZoneExists() { + timeZone.assertExists() + } + + fun verifyTimeZoneValueExists() { + timeZoneValue.assertExists() + } + + infix fun call(func: ContactMeScreenCallRobot.() -> Unit): ContactMeScreenCallRobot { + call.assertExists().performClick() + return contactMeScreenCallRobot(composeTestRule) { + setIsCallClicked(callClicked) + func() + } + } +} + +internal fun contactMeScreenCallRobot( + composeTestRule: AndroidComposeTestRule, ComponentActivity>, + func: ContactMeScreenCallRobot.() -> Unit, +) = ContactMeScreenCallRobot(composeTestRule).apply(func) + +internal open class ContactMeScreenCallRobot( + private val composeTestRule: AndroidComposeTestRule, ComponentActivity>, +) { + + private var isCallClicked: Boolean = false + + fun setIsCallClicked(backClicked: Boolean) { + isCallClicked = backClicked + } + + fun isCall() { + Truth.assertThat(isCallClicked).isTrue() + } +} + +val testUiState = ContactMeViewState( + nameTw = NAME_TW, + nameEng = NAME_ENG, + position = POSITION, + phone = PHONE, + linkedinUrl = LINKEDIN_URL, + email = EMAIL, + timeZone = TIME_ZONE, +) diff --git a/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenTest.kt b/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenTest.kt new file mode 100644 index 0000000..e283d82 --- /dev/null +++ b/feature/contactme/src/androidTest/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreenTest.kt @@ -0,0 +1,56 @@ +package com.wei.picquest.feature.contactme.contactme + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.window.layout.DisplayFeature +import com.wei.picquest.core.designsystem.ui.PqContentType +import org.junit.Rule +import org.junit.Test + +/** + * UI tests for [ContactMeScreen] composable. + */ +class ContactMeScreenTest { + + /** + * 通常我們使用 createComposeRule(),作為 composeTestRule + * + * 但若測試案例需查找資源檔 e.g. R.string.welcome。 + * 使用 createAndroidComposeRule(),作為 composeTestRule + */ + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun checkElementsVisibility_afterOpeningTheScreen() { + contactMeScreenRobot(composeTestRule) { + setContactMeScreenContent( + contentType = PqContentType.SINGLE_PANE, + displayFeature = emptyList(), + ) + + verifyProfilePictureDisplayed() + verifyNameDisplayed() + verifyPositionDisplayed() + verifyCallDisplayed() + verifyLinkedinExists() + verifyLinkedinValueExists() + verifyEmailExists() + verifyEmailValueExists() + verifyTimeZoneExists() + verifyTimeZoneValueExists() + } + } + + @Test + fun checkCallButtonAction_afterPress() { + contactMeScreenRobot(composeTestRule) { + setContactMeScreenContent( + contentType = PqContentType.SINGLE_PANE, + displayFeature = emptyList(), + ) + } call { + isCall() + } + } +} diff --git a/feature/contactme/src/main/AndroidManifest.xml b/feature/contactme/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/contactme/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreen.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreen.kt new file mode 100644 index 0000000..d871584 --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeScreen.kt @@ -0,0 +1,457 @@ +package com.wei.picquest.feature.contactme.contactme + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.window.layout.DisplayFeature +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.wei.picquest.core.designsystem.component.FunctionalityNotAvailablePopup +import com.wei.picquest.core.designsystem.component.baselineHeight +import com.wei.picquest.core.designsystem.component.coilImagePainter +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.PqTheme +import com.wei.picquest.core.designsystem.theme.SPACING_EXTRA_LARGE +import com.wei.picquest.core.designsystem.theme.SPACING_LARGE +import com.wei.picquest.core.designsystem.theme.SPACING_SMALL +import com.wei.picquest.core.designsystem.ui.DeviceLandscapePreviews +import com.wei.picquest.core.designsystem.ui.DevicePortraitPreviews +import com.wei.picquest.core.designsystem.ui.PqContentType +import com.wei.picquest.core.designsystem.ui.PqNavigationType +import com.wei.picquest.feature.contactme.R +import com.wei.picquest.feature.contactme.contactme.ui.DecorativeBackgroundText +import com.wei.picquest.feature.contactme.contactme.ui.ProfileProperty +import com.wei.picquest.feature.contactme.contactme.ui.decorativeTextStyle +import com.wei.picquest.feature.contactme.contactme.utilities.EMAIL +import com.wei.picquest.feature.contactme.contactme.utilities.LINKEDIN_URL +import com.wei.picquest.feature.contactme.contactme.utilities.NAME_ENG +import com.wei.picquest.feature.contactme.contactme.utilities.PHONE +import com.wei.picquest.feature.contactme.contactme.utilities.POSITION +import com.wei.picquest.feature.contactme.contactme.utilities.TIME_ZONE + +/** + * + * UI 事件決策樹 + * 下圖顯示了一個決策樹,用於查找處理特定事件用例的最佳方法。 + * + * ┌───────┐ + * │ Start │ + * └───┬───┘ + * ↓ + * ┌───────────────────────────────────┐ + * │ Where is event originated? │ + * └──────┬─────────────────────┬──────┘ + * ↓ ↓ + * UI ViewModel + * │ │ + * ┌─────────────────────────┐ ┌───────────────┐ + * │ When the event requires │ │ Update the UI │ + * │ ... │ │ State │ + * └─┬─────────────────────┬─┘ └───────────────┘ + * ↓ ↓ + * Business logic UI behavior logic + * │ │ + * ┌─────────────────────────────────┐ ┌──────────────────────────────────────┐ + * │ Delegate the business logic to │ │ Modify the UI element state in the │ + * │ the ViewModel │ │ UI directly │ + * └─────────────────────────────────┘ └──────────────────────────────────────┘ + * + * + */ +@Composable +internal fun ContactMeRoute( + navController: NavController, + contentType: PqContentType, + displayFeatures: List, + navigationType: PqNavigationType, + viewModel: ContactMeViewModel = hiltViewModel(), +) { + val uiStates: ContactMeViewState by viewModel.states.collectAsStateWithLifecycle() + + ContactMeScreen( + uiStates = uiStates, + contentType = contentType, + displayFeatures = displayFeatures, + navigationType = navigationType, + onPhoneClick = { viewModel.dispatch(ContactMeViewAction.Call) }, + ) +} + +@Composable +internal fun ContactMeScreen( + uiStates: ContactMeViewState, + contentType: PqContentType, + displayFeatures: List, + navigationType: PqNavigationType = PqNavigationType.PERMANENT_NAVIGATION_DRAWER, + isPreview: Boolean = false, + onPhoneClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxSize(), + ) { + if (contentType == PqContentType.DUAL_PANE) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + DecorativeBackgroundText( + text = uiStates.nameEng, + repetitions = 3, + rotationZ = -25f, + scale = 2.5f, + textStyle = decorativeTextStyle(MaterialTheme.colorScheme.error), + ) + TwoPane( + first = { + ContactMeTwoPaneFirstContent( + uiStates = uiStates, + isPreview = isPreview, + ) + }, + second = { + ContactMeTwoPaneSecondContent( + uiStates = uiStates, + navigationType = navigationType, + onPhoneClick = onPhoneClick, + ) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = SPACING_LARGE.dp), + displayFeatures = displayFeatures, + ) + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + DecorativeBackgroundText( + text = uiStates.nameEng, + repetitions = 3, + rotationZ = -25f, + scale = 2.5f, + textStyle = decorativeTextStyle(MaterialTheme.colorScheme.primary), + ) + ContactMeSinglePaneContent( + uiStates = uiStates, + onPhoneClick = onPhoneClick, + isPreview = isPreview, + ) + } + } + } +} + +@Composable +internal fun ContactMeTwoPaneFirstContent( + uiStates: ContactMeViewState, + isPreview: Boolean, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val modifier = Modifier + .clip(CircleShape) + .size(200.dp) + .border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + + DisplayHeadShot( + modifier = modifier, + name = uiStates.nameEng, + isPreview = isPreview, + ) + } +} + +@Composable +internal fun ContactMeTwoPaneSecondContent( + uiStates: ContactMeViewState, + navigationType: PqNavigationType, + onPhoneClick: () -> Unit, + withTopSpacer: Boolean = true, + withBottomSpacer: Boolean = true, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + if (withTopSpacer) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Spacer(modifier = Modifier.height(SPACING_LARGE.dp)) + } + } + item { + ContactMeCard( + uiStates = uiStates, + onPhoneClick = onPhoneClick, + ) + } + if (withBottomSpacer) { + item { + Spacer(modifier = Modifier.height(SPACING_LARGE.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } + } +} + +@Composable +internal fun ContactMeSinglePaneContent( + uiStates: ContactMeViewState, + onPhoneClick: () -> Unit, + isPreview: Boolean, + withTopSpacer: Boolean = true, + withBottomSpacer: Boolean = true, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (withTopSpacer) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Spacer(modifier = Modifier.height(SPACING_LARGE.dp)) + } + } + item { + val modifier = Modifier + .clip(CircleShape) + .size(150.dp) + .border(2.dp, MaterialTheme.colorScheme.onBackground, CircleShape) + + DisplayHeadShot( + modifier = modifier, + name = uiStates.nameEng, + isPreview = isPreview, + ) + } + item { + Spacer(modifier = Modifier.height(SPACING_SMALL.dp)) + ContactMeCard( + uiStates = uiStates, + onPhoneClick = onPhoneClick, + ) + } + if (withBottomSpacer) { + item { + Spacer(modifier = Modifier.height(SPACING_LARGE.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } +} + +@Composable +internal fun DisplayHeadShot( + modifier: Modifier = Modifier, + name: String, + isPreview: Boolean, +) { + val resId = R.drawable.he_wei + val painter = coilImagePainter(resId, isPreview) + + val profilePictureDescription = stringResource(R.string.profile_picture).format(name) + Image( + painter = painter, + contentDescription = profilePictureDescription, + modifier = modifier + .clip(CircleShape) + .size(300.dp) + .border(2.dp, MaterialTheme.colorScheme.onBackground, CircleShape), + ) +} + +@Composable +fun ContactMeCard( + uiStates: ContactMeViewState, + modifier: Modifier = Modifier, + onPhoneClick: () -> Unit, +) { + Card( + modifier = modifier + .padding(horizontal = SPACING_EXTRA_LARGE.dp) + .clip(CardDefaults.shape), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .padding(SPACING_LARGE.dp) + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + NameAndPosition( + uiStates = uiStates, + modifier = Modifier.weight(1f), + ) + PhoneButton( + uiStates.nameEng, + onPhoneClick = onPhoneClick, + ) + } + ProfileProperty( + label = stringResource(id = R.string.linkedin), + value = uiStates.linkedinUrl, + isLink = true, + ) + ProfileProperty( + label = stringResource(id = R.string.email), + value = uiStates.email, + isLink = true, + ) + ProfileProperty( + label = stringResource(id = R.string.timezone), + value = uiStates.timeZone, + isLink = false, + ) + } + } +} + +@Composable +private fun NameAndPosition( + uiStates: ContactMeViewState, + modifier: Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + ) { + val name = uiStates.nameEng + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .baselineHeight(32.dp) + .semantics { contentDescription = name }, + ) + val position = uiStates.position + Text( + text = position, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(bottom = 20.dp) + .baselineHeight(SPACING_EXTRA_LARGE.dp) + .semantics { contentDescription = position }, + ) + } +} + +@Composable +private fun PhoneButton( + name: String, + onPhoneClick: () -> Unit, +) { + val showPopup = remember { mutableStateOf(false) } + + if (showPopup.value) { + FunctionalityNotAvailablePopup(onDismiss = { + showPopup.value = false + }) + } + + val phoneDescription = stringResource(id = R.string.call).format(name) + IconButton( + onClick = { + showPopup.value = true + onPhoneClick() + }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .semantics { contentDescription = phoneDescription }, + ) { + Icon( + imageVector = PqIcons.Phone, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + ) + } +} + +@DeviceLandscapePreviews() +@Composable +fun ContactMeScreenDualPanePreview() { + PqTheme { + ContactMeScreen( + uiStates = previewUIState, + contentType = PqContentType.DUAL_PANE, + displayFeatures = emptyList(), + onPhoneClick = { }, + ) + } +} + +@DevicePortraitPreviews() +@Composable +fun ContactMeScreenSinglePanePreview() { + PqTheme { + ContactMeScreen( + uiStates = previewUIState, + contentType = PqContentType.SINGLE_PANE, + displayFeatures = emptyList(), + isPreview = true, + onPhoneClick = { }, + ) + } +} + +internal val previewUIState = ContactMeViewState( + nameEng = NAME_ENG, + position = POSITION, + phone = PHONE, + linkedinUrl = LINKEDIN_URL, + email = EMAIL, + timeZone = TIME_ZONE, +) diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewModel.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewModel.kt new file mode 100644 index 0000000..92e6b0e --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewModel.kt @@ -0,0 +1,47 @@ +package com.wei.picquest.feature.contactme.contactme + +import com.wei.picquest.core.base.BaseViewModel +import com.wei.picquest.feature.contactme.contactme.utilities.EMAIL +import com.wei.picquest.feature.contactme.contactme.utilities.LINKEDIN_URL +import com.wei.picquest.feature.contactme.contactme.utilities.NAME_ENG +import com.wei.picquest.feature.contactme.contactme.utilities.NAME_TW +import com.wei.picquest.feature.contactme.contactme.utilities.PHONE +import com.wei.picquest.feature.contactme.contactme.utilities.POSITION +import com.wei.picquest.feature.contactme.contactme.utilities.TIME_ZONE +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ContactMeViewModel @Inject constructor() : BaseViewModel< + ContactMeViewAction, + ContactMeViewState, + >(ContactMeViewState()) { + + init { + getProfile() + } + + private fun getProfile() { + updateState { + copy( + nameTw = NAME_TW, + nameEng = NAME_ENG, + position = POSITION, + phone = PHONE, + linkedinUrl = LINKEDIN_URL, + email = EMAIL, + timeZone = TIME_ZONE, + ) + } + } + + private fun callPhone() { + // TODO + } + + override fun dispatch(action: ContactMeViewAction) { + when (action) { + is ContactMeViewAction.Call -> callPhone() + } + } +} diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewState.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewState.kt new file mode 100644 index 0000000..5a6fc8a --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ContactMeViewState.kt @@ -0,0 +1,18 @@ +package com.wei.picquest.feature.contactme.contactme + +import com.wei.picquest.core.base.Action +import com.wei.picquest.core.base.State + +sealed class ContactMeViewAction : Action { + object Call : ContactMeViewAction() +} + +data class ContactMeViewState( + val nameTw: String = "N/A", + val nameEng: String = "N/A", + val position: String = "N/A", + val phone: String = "N/A", + val linkedinUrl: String = "N/A", + val email: String = "N/A", + val timeZone: String = "N/A", +) : State diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/navigation/ContactMeNavigation.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/navigation/ContactMeNavigation.kt new file mode 100644 index 0000000..276d34e --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/navigation/ContactMeNavigation.kt @@ -0,0 +1,33 @@ +package com.wei.picquest.feature.contactme.contactme.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.window.layout.DisplayFeature +import com.wei.picquest.core.designsystem.ui.PqContentType +import com.wei.picquest.core.designsystem.ui.PqNavigationType +import com.wei.picquest.feature.contactme.contactme.ContactMeRoute + +const val contactMeRoute = "contact_me_route" + +fun NavController.navigateToContactMe(navOptions: NavOptions? = null) { + this.navigate(contactMeRoute, navOptions) +} + +fun NavGraphBuilder.contactMeGraph( + navController: NavController, + contentType: PqContentType, + displayFeatures: List, + navigationType: PqNavigationType, + nestedGraphs: NavGraphBuilder.() -> Unit, +) { + composable(route = contactMeRoute) { + ContactMeRoute( + navController = navController, + contentType = contentType, + displayFeatures = displayFeatures, + navigationType = navigationType, + ) + } +} diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/DecorativeBackgroundText.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/DecorativeBackgroundText.kt new file mode 100644 index 0000000..107d8a6 --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/DecorativeBackgroundText.kt @@ -0,0 +1,54 @@ +package com.wei.picquest.feature.contactme.contactme.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign + +@Composable +internal fun DecorativeBackgroundText( + text: String, + repetitions: Int, + rotationZ: Float = 0f, + scale: Float = 1f, + textStyle: TextStyle = TextStyle( + fontSize = MaterialTheme.typography.displayLarge.fontSize, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + ), +) { + Column( + modifier = Modifier + .graphicsLayer(rotationZ = rotationZ) + .scale(scale), + ) { + repeat(repetitions) { + Text( + text = text, + style = textStyle, + modifier = Modifier.semantics { contentDescription = "" }, + ) + } + } +} + +@Composable +fun decorativeTextStyle(color: Color) = TextStyle( + fontSize = MaterialTheme.typography.displayLarge.fontSize, + color = color.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, +) diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/ProfileProperty.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/ProfileProperty.kt new file mode 100644 index 0000000..fed6f8c --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/ui/ProfileProperty.kt @@ -0,0 +1,42 @@ +package com.wei.picquest.feature.contactme.contactme.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.component.baselineHeight +import com.wei.picquest.core.designsystem.theme.SPACING_EXTRA_LARGE +import com.wei.picquest.core.designsystem.theme.SPACING_MEDIUM + +@Composable +fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { + Column(modifier = Modifier.padding(vertical = SPACING_MEDIUM.dp)) { + Divider(color = MaterialTheme.colorScheme.outline) + Text( + text = label, + modifier = Modifier + .baselineHeight(SPACING_EXTRA_LARGE.dp) + .semantics { contentDescription = label }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val style = if (isLink) { + MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + } else { + MaterialTheme.typography.bodyLarge + } + Text( + text = value, + modifier = Modifier + .baselineHeight(SPACING_EXTRA_LARGE.dp) + .semantics { contentDescription = value }, + style = style, + ) + } +} diff --git a/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/utilities/Constants.kt b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/utilities/Constants.kt new file mode 100644 index 0000000..e113b00 --- /dev/null +++ b/feature/contactme/src/main/java/com/wei/picquest/feature/contactme/contactme/utilities/Constants.kt @@ -0,0 +1,12 @@ +package com.wei.picquest.feature.contactme.contactme.utilities + +/** + * Constants used throughout the feature:contactme. + */ +const val NAME_TW = "HE, XUAN-WEI" +const val NAME_ENG = "HE, XUAN-WEI" +const val POSITION = "Android Developer" +const val PHONE = "+886-900-123-456" +const val LINKEDIN_URL = "in/he-xuen-wei" +const val EMAIL = "azrael8576love2008@gmail.com" +const val TIME_ZONE = "Asia/Taipei GMT +08:00" diff --git a/feature/contactme/src/main/res/drawable/he_wei.jpg b/feature/contactme/src/main/res/drawable/he_wei.jpg new file mode 100644 index 0000000..5f280cf Binary files /dev/null and b/feature/contactme/src/main/res/drawable/he_wei.jpg differ diff --git a/feature/contactme/src/main/res/values-zh-rTW/strings.xml b/feature/contactme/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..9fc1d69 --- /dev/null +++ b/feature/contactme/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,8 @@ + + + %s 的大頭貼 + LinkedIn + Email + 時區 + 撥打給 %s + \ No newline at end of file diff --git a/feature/contactme/src/main/res/values/strings.xml b/feature/contactme/src/main/res/values/strings.xml new file mode 100644 index 0000000..b426f46 --- /dev/null +++ b/feature/contactme/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + %s\'s profile picture + LinkedIn + Email + Timezone + Call %s + \ No newline at end of file diff --git a/feature/contactme/utilities/Constants.kt b/feature/contactme/utilities/Constants.kt new file mode 100644 index 0000000..7a5580d --- /dev/null +++ b/feature/contactme/utilities/Constants.kt @@ -0,0 +1,12 @@ +package com.wei.amazingtalker.feature.contactme.utilities + +/** + * Constants used throughout the feature:contactme. + */ +const val NAME_TW = "HE, XUAN-WEI" +const val NAME_ENG = "HE, XUAN-WEI" +const val POSITION = "Android Developer" +const val PHONE = "+886-900-123-456" +const val LINKEDIN_URL = "in/he-xuen-wei" +const val EMAIL = "azrael8576love2008@gmail.com" +const val TIME_ZONE = "Asia/Taipei GMT +08:00" diff --git a/settings.gradle.kts b/settings.gradle.kts index 621bb1e..d475a70 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":ui-test-hilt-manifest") include(":feature:home") include(":feature:photo") +include(":feature:contactme")