diff --git a/app/src/main/kotlin/nl/eduid/di/api/EduIdApi.kt b/app/src/main/kotlin/nl/eduid/di/api/EduIdApi.kt index bcaffc2..7362e08 100644 --- a/app/src/main/kotlin/nl/eduid/di/api/EduIdApi.kt +++ b/app/src/main/kotlin/nl/eduid/di/api/EduIdApi.kt @@ -8,7 +8,6 @@ import nl.eduid.di.model.EmailChangeRequest import nl.eduid.di.model.EnrollResponse import nl.eduid.di.model.IdpScoping import nl.eduid.di.model.InstitutionNameResponse -import nl.eduid.di.model.LinkedAccount import nl.eduid.di.model.LinkedAccountUpdateRequest import nl.eduid.di.model.RequestEduIdAccount import nl.eduid.di.model.RequestPhoneCode @@ -78,6 +77,12 @@ interface EduIdApi { @Query("schac_home") schac_home: String, ): Response + @PUT("/mobile/api/sp/prefer-linked-account") + suspend fun preferLinkedAccount( + @Body request: LinkedAccountUpdateRequest, + ): Response + + @GET("/mobile/api/sp/confirm-email") suspend fun confirmEmail( @Query("h") hash: String, diff --git a/app/src/main/kotlin/nl/eduid/di/assist/DataAssistant.kt b/app/src/main/kotlin/nl/eduid/di/assist/DataAssistant.kt index eaba43b..418f6c7 100644 --- a/app/src/main/kotlin/nl/eduid/di/assist/DataAssistant.kt +++ b/app/src/main/kotlin/nl/eduid/di/assist/DataAssistant.kt @@ -1,6 +1,7 @@ package nl.eduid.di.assist import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -28,7 +29,7 @@ constructor( private val cachedDetails = MutableStateFlow?>(null) val observableDetails: Flow?> = cachedDetails.map { knownValue -> if (knownValue == null) { - val fromNetwork = try { loadInCache() } catch (ex: Exception) { return@map handleError(ex) } + val fromNetwork = try { loadInCache() } catch (ex: Exception) { return@map handleError(ex) } fromNetwork.fold( onSuccess = { SaveableResult.Success(it) @@ -42,6 +43,16 @@ constructor( } } + suspend fun refreshDetails() = withContext(dispatcher) { + val fromNetwork = loadInCache() + cachedDetails.emit( + fromNetwork.fold( + onSuccess = { SaveableResult.Success(it) }, + onFailure = { handleError(it) } + ) + ) + } + private suspend fun handleError(ex: Throwable): SaveableResult.LoadError { return if (ex is UnauthorizedException) { storageRepository.clearInvalidAuth() @@ -144,6 +155,16 @@ constructor( ) } + suspend fun preferLinkedAccount(request: LinkedAccountUpdateRequest) = withContext(dispatcher) { + if (infoRepository.preferLinkedAccount(request)) { + // Also refresh the details, if it was a success, so we have the latest data + refreshDetails() + true + } else { + false + } + } + suspend fun getExternalAccountLinkResult(idpScoping: IdpScoping, bankId: String?): String? = withContext(dispatcher) { val result = infoRepository.getExternalAccountLinkResult(idpScoping, bankId) result.fold( diff --git a/app/src/main/kotlin/nl/eduid/di/model/EduIdModels.kt b/app/src/main/kotlin/nl/eduid/di/model/EduIdModels.kt index 31ec590..4c3e41b 100644 --- a/app/src/main/kotlin/nl/eduid/di/model/EduIdModels.kt +++ b/app/src/main/kotlin/nl/eduid/di/model/EduIdModels.kt @@ -59,6 +59,7 @@ data class UserDetails( val givenName: String, val chosenName: String, val familyName: String, + val dateOfBirth: Long?, val usePassword: Boolean, val usePublicKey: Boolean, val forgottenPassword: Boolean, @@ -150,8 +151,8 @@ data class Registration( @Parcelize @JsonClass(generateAdapter = true) data class InstitutionNameResponse( - val displayNameEn: String, - val displayNameNl: String, + val displayNameEn: String?, + val displayNameNl: String?, ) : Parcelable @Parcelize diff --git a/app/src/main/kotlin/nl/eduid/di/model/ModelToState.kt b/app/src/main/kotlin/nl/eduid/di/model/ModelToState.kt index 22e7764..8d68360 100644 --- a/app/src/main/kotlin/nl/eduid/di/model/ModelToState.kt +++ b/app/src/main/kotlin/nl/eduid/di/model/ModelToState.kt @@ -24,6 +24,12 @@ fun LinkedAccount.mapToInstitutionAccount(): PersonalInfo.InstitutionAccount? = familyName = this.familyName, createdStamp = this.createdAt, expiryStamp = this.expiresAt, + updateRequest = LinkedAccountUpdateRequest( + eduPersonPrincipalName = this.eduPersonPrincipalName, + subjectId = this.subjectId, + external = false, + idpScoping = null + ) ) } @@ -39,6 +45,12 @@ fun ExternalLinkedAccount.mapToInstitutionAccount(): PersonalInfo.InstitutionAcc dateOfBirth = this.dateOfBirth?.let { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) }, createdStamp = this.createdAt, expiryStamp = this.expiresAt, + updateRequest = LinkedAccountUpdateRequest( + eduPersonPrincipalName = null, + subjectId = this.subjectId, + external = true, + idpScoping = this.idpScoping + ) ) @@ -57,6 +69,8 @@ fun UserDetails.mapToPersonalInfo(): PersonalInfo { } ?: "${this.chosenName} ${this.familyName}" val email: String = this.email + val dateOfBirth: LocalDate? = this.dateOfBirth?.let { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) } + val linkedInternalAccounts = linkedAccounts.mapNotNull { account -> account.mapToInstitutionAccount() }.toImmutableList() @@ -77,6 +91,7 @@ fun UserDetails.mapToPersonalInfo(): PersonalInfo { givenName = givenNameConfirmer?.givenName, givenNameConfirmedBy = givenNameConfirmer?.institutionIdentifier ), + dateOfBirth = dateOfBirth, nameProvider = nameProvider, email = email, linkedInternalAccounts = linkedInternalAccounts, diff --git a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt index f6da233..41a2f97 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt @@ -311,7 +311,7 @@ fun MainGraph( composable(Graph.FIRST_TIME_DIALOG) { entry -> val viewModel = hiltViewModel(entry) FirstTimeDialogRoute(viewModel = viewModel, - goToAccountLinked = { navController.goToWithPopCurrent(AccountLinked.route) }, + goToAccountLinked = { navController.goToWithPopCurrent(AccountLinked.routeWithRegistrationFlowParam(true)) }, skipThis = { navController.navigate(Graph.HOME_PAGE) { //Clear existing home page that has no account @@ -332,7 +332,8 @@ fun MainGraph( }) } composable(//region Account Linked - route = AccountLinked.route, deepLinks = listOf( + route = AccountLinked.routeWithArgs, + deepLinks = listOf( navDeepLink { uriPattern = AccountLinked.getUriPatternOK(baseUrl) }, @@ -342,7 +343,8 @@ fun MainGraph( navDeepLink { uriPattern = AccountLinked.getUriPatternExpired(baseUrl) }, - ) + ), + arguments = AccountLinked.arguments ) { entry -> val deepLinkIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { entry.arguments?.getParcelable( @@ -360,7 +362,13 @@ fun MainGraph( AccountLinkedScreen( viewModel = viewModel, result = result, - continueToHome = { navController.goToWithPopCurrent(Graph.HOME_PAGE) }, + continueToHome = { + navController.goToWithPopCurrent(Graph.HOME_PAGE) + }, + continueToPersonalInfo = { + navController.goToWithPopCurrent(Graph.HOME_PAGE) //Clear the entire backstack + navController.navigate(Graph.PERSONAL_INFO) + } ) }//endregion //endregion diff --git a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt index da76836..28d7cbe 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt @@ -5,6 +5,9 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.navArgument import nl.eduid.di.model.SelfAssertedName +import nl.eduid.graphs.RequestEduIdLinkSent.LOGIN_REASON +import nl.eduid.graphs.RequestEduIdLinkSent.emailArg +import nl.eduid.graphs.RequestEduIdLinkSent.reasonArg import java.io.UnsupportedEncodingException object Graph { @@ -48,6 +51,19 @@ object AccountLinked { fun getUriPatternOK(baseUrl: String) = "$baseUrl/client/mobile/account-linked" fun getUriPatternFailed(baseUrl: String) = "$baseUrl/client/mobile/eppn-already-linked" fun getUriPatternExpired(baseUrl: String) = "$baseUrl/client/mobile/expired" + + const val isRegistrationFlowArg = "is_registration_flow" + + const val routeWithArgs = "${route}?isRegistrationFlow={$isRegistrationFlowArg}" + val arguments = listOf(navArgument(isRegistrationFlowArg) { + type = NavType.BoolType + nullable = false + defaultValue = false + }) + + fun routeWithRegistrationFlowParam(isRegistrationFlow: Boolean) = + "$route/isRegistrationFlow=$isRegistrationFlow" + } object VerifiedPersonalInfoRoute { diff --git a/app/src/main/kotlin/nl/eduid/screens/accountlinked/AccountLinkedScreen.kt b/app/src/main/kotlin/nl/eduid/screens/accountlinked/AccountLinkedScreen.kt index 4e3f0cc..1d00251 100644 --- a/app/src/main/kotlin/nl/eduid/screens/accountlinked/AccountLinkedScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/accountlinked/AccountLinkedScreen.kt @@ -8,17 +8,22 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -26,30 +31,50 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import nl.eduid.ErrorData import nl.eduid.R +import nl.eduid.di.model.LinkedAccount +import nl.eduid.di.model.LinkedAccountUpdateRequest import nl.eduid.screens.personalinfo.PersonalInfo import nl.eduid.screens.personalinfo.PersonalInfoViewModel import nl.eduid.ui.AlertDialogWithSingleButton +import nl.eduid.ui.ConnectionCard import nl.eduid.ui.ConnectionCardOld import nl.eduid.ui.EduIdTopAppBar import nl.eduid.ui.InfoField import nl.eduid.ui.InfoFieldOld import nl.eduid.ui.PrimaryButton +import nl.eduid.ui.SecondaryButton +import nl.eduid.ui.VerifiedInfoField +import nl.eduid.ui.getShortDateString import nl.eduid.ui.theme.ColorMain_Green_400 import nl.eduid.ui.theme.EduidAppAndroidTheme +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale @Composable fun AccountLinkedScreen( viewModel: PersonalInfoViewModel, result: ResultAccountLinked, continueToHome: () -> Unit, + continueToPersonalInfo: () -> Unit ) = EduIdTopAppBar( withBackIcon = false ) { + val accountLinked by viewModel.accountLinked.collectAsStateWithLifecycle() + LaunchedEffect(key1 = accountLinked) { + if (accountLinked) { + continueToPersonalInfo() + } + } + LaunchedEffect(viewModel) { + viewModel.refreshPersonalInfo() + } Column( modifier = Modifier .fillMaxSize() .padding(it) ) { + when (result) { is ResultAccountLinked.FailedAlreadyLinkedResult -> AccountFailedLinkContent( explanation = stringResource( @@ -65,13 +90,23 @@ fun AccountLinkedScreen( is ResultAccountLinked.Success -> { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val errorData by viewModel.errorData.collectAsStateWithLifecycle() + val linkedAccount = viewModel.findLinkedAccount(uiState.personalInfo, result.institutionId) + val isLinkingAccount by viewModel.isProcessing.collectAsStateWithLifecycle() AccountLinkedContent( - personalInfo = uiState.personalInfo, isLoading = uiState.isLoading, errorData = errorData, dismissError = viewModel::clearErrorData, continueToHome = continueToHome, - removeConnection = { institutionId -> viewModel.removeConnection(institutionId) }, + linkedAccount = linkedAccount, + isRegistrationFlow = viewModel.isRegistrationFlow, + isFirstLinkedAccount = viewModel.isFirstLinkedAccount(uiState.personalInfo), + isLinkingAccount = isLinkingAccount, + preferLinkedAccount = { + if (linkedAccount != null) { + viewModel.preferLinkedAccount(linkedAccount) + } + }, + continueToPersonalInfo = continueToPersonalInfo ) } } @@ -91,7 +126,7 @@ private fun AccountFailedLinkContent( ) { Column( horizontalAlignment = Alignment.Start, - modifier = Modifier.verticalScroll(rememberScrollState()) + modifier = Modifier.fillMaxSize() ) { Spacer(Modifier.height(36.dp)) Text( @@ -124,15 +159,19 @@ private fun AccountFailedLinkContent( @Composable private fun AccountLinkedContent( - personalInfo: PersonalInfo, + linkedAccount: PersonalInfo.InstitutionAccount?, + isRegistrationFlow: Boolean, + isFirstLinkedAccount: Boolean, isLoading: Boolean = false, + isLinkingAccount: Boolean = false, errorData: ErrorData? = null, - dismissError: () -> Unit = {}, - continueToHome: () -> Unit = {}, - removeConnection: (String) -> Unit = {}, + dismissError: () -> Unit, + preferLinkedAccount: () -> Unit, + continueToPersonalInfo: () -> Unit, + continueToHome: () -> Unit, ) = Column( modifier = Modifier - .verticalScroll(rememberScrollState()) + .fillMaxSize() .navigationBarsPadding() .padding(start = 24.dp, end = 24.dp, bottom = 24.dp) ) { @@ -166,41 +205,76 @@ private fun AccountLinkedContent( ) Spacer(modifier = Modifier.height(16.dp)) } else { - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(24.dp)) } - InfoFieldOld( - title = personalInfo.name, - subtitle = if (personalInfo.nameProvider == null) { - stringResource(R.string.Profile_ProvidedByYou_COPY) - } else { - stringResource(R.string.Profile_ProvidedBy_COPY, personalInfo.nameProvider) - }, - endIcon = R.drawable.shield_tick_blue, - ) - Spacer(Modifier.height(16.dp)) - if (personalInfo.linkedInternalAccounts.isNotEmpty()) { + linkedAccount?.roleProvider?.let { roleProvider -> + ConnectionCard( + institutionName = roleProvider, + role = (linkedAccount.role ?: linkedAccount.subjectId).replaceFirstChar { it.titlecase() }, + confirmedByInstitution = linkedAccount, + isExpandable = false + ) + } + Spacer(Modifier.height(24.dp)) + if (!isFirstLinkedAccount) { + HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(24.dp)) Text( - text = stringResource(R.string.Profile_OrganisationsHeader_COPY), - style = MaterialTheme.typography.bodyLarge.copy( - textAlign = TextAlign.Start, - fontWeight = FontWeight.SemiBold, - ), + style = MaterialTheme.typography.bodyLarge, + text = stringResource(R.string.LinkingSuccess_SubtitlePreferInstitution_COPY), + modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.height(6.dp)) + Spacer(Modifier.height(24.dp)) } - personalInfo.linkedInternalAccounts.forEach { account -> - ConnectionCardOld( - title = account.role ?: account.subjectId, - subtitle = stringResource(R.string.Profile_InstitutionAt_COPY, account.roleProvider ?: account.institution), - institutionInfo = account, - onRemoveConnection = { removeConnection(account.subjectId) }, + // Verified given name + linkedAccount?.givenName?.let { givenName -> + VerifiedInfoField( + title = givenName, + subtitle = stringResource(R.string.Profile_VerifiedGivenName_COPY), ) + Spacer(Modifier.height(24.dp)) } - PrimaryButton( - text = stringResource(R.string.NameUpdated_Continue_COPY), - onClick = continueToHome, - modifier = Modifier.fillMaxWidth(), - ) + // Verified family name + linkedAccount?.familyName?.let { familyName -> + VerifiedInfoField( + title = familyName, + subtitle = stringResource(R.string.Profile_VerifiedFamilyName_COPY), + ) + Spacer(Modifier.height(24.dp)) + } + // Date of birth + linkedAccount?.dateOfBirth?.let { dateOfBirth -> + VerifiedInfoField( + title = dateOfBirth.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli().getShortDateString(), + subtitle = stringResource(R.string.Profile_VerifiedDateOfBirth_COPY), + ) + Spacer(Modifier.height(24.dp)) + } + Spacer(Modifier.weight(1f)) + if (isFirstLinkedAccount) { + PrimaryButton( + text = stringResource(R.string.LinkingSuccess_Button_Continue_COPY), + onClick = if (isRegistrationFlow) continueToHome else continueToPersonalInfo, + modifier = Modifier.fillMaxWidth(), + ) + } else { + PrimaryButton( + text = stringResource(R.string.LinkingSuccess_Button_YesPlease_COPY), + onClick = preferLinkedAccount, + enabled = !isLinkingAccount, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(24.dp)) + PrimaryButton( + text = stringResource(R.string.LinkingSuccess_Button_NoThanks_COPY), + onClick = if (isRegistrationFlow) continueToHome else continueToPersonalInfo, + buttonBackgroundColor = Color.Transparent, + enabled = !isLinkingAccount, + buttonTextColor = ColorMain_Green_400, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(16.dp)) } @@ -208,6 +282,25 @@ private fun AccountLinkedContent( @Composable private fun Preview_AccountLinkedContent() = EduidAppAndroidTheme { AccountLinkedContent( - personalInfo = PersonalInfo.demoData() + linkedAccount = PersonalInfo.InstitutionAccount( + givenName = "Given name", + familyName = "Family name", + dateOfBirth = LocalDate.now(), + subjectId = "1", + role = "Librarian", + roleProvider = "Library", + institution = "University of Amsterdam", + createdStamp = 0, + expiryStamp = 0, + updateRequest = LinkedAccountUpdateRequest(null, null, false, null) + ), + isFirstLinkedAccount = false, + isLoading = false, + isRegistrationFlow = false, + errorData = null, + dismissError = { }, + preferLinkedAccount = {}, + continueToHome = { }, + continueToPersonalInfo = { } ) } \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoData.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoData.kt index d40ae7b..23c16ee 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoData.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoData.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import nl.eduid.di.model.ConfirmedName +import nl.eduid.di.model.LinkedAccountUpdateRequest import nl.eduid.di.model.SelfAssertedName import java.time.LocalDate @@ -14,6 +15,7 @@ data class PersonalInfo( val confirmedName: ConfirmedName = ConfirmedName(), val nameProvider: String? = null, val email: String = "", + val dateOfBirth: LocalDate? = null, val linkedInternalAccounts: ImmutableList = emptyList().toImmutableList(), val linkedExternalAccounts: ImmutableList = emptyList().toImmutableList(), val dateCreated: Long = 0, @@ -31,6 +33,7 @@ data class PersonalInfo( val dateOfBirth: LocalDate? = null, val createdStamp: Long, val expiryStamp: Long, + val updateRequest: LinkedAccountUpdateRequest ) companion object { @@ -70,7 +73,8 @@ data class PersonalInfo( givenName = "Horace", familyName = "Worblehat", createdStamp = System.currentTimeMillis(), - expiryStamp = System.currentTimeMillis() + expiryStamp = System.currentTimeMillis(), + updateRequest = LinkedAccountUpdateRequest("1", "1", false, null) ) ).toImmutableList() } diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoRepository.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoRepository.kt index 369d295..af741d5 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoRepository.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoRepository.kt @@ -384,6 +384,14 @@ class PersonalInfoRepository( false } + suspend fun preferLinkedAccount(linkedAccountUpdateRequest: LinkedAccountUpdateRequest) = try { + val response = eduIdApi.preferLinkedAccount(linkedAccountUpdateRequest) + response.isSuccessful + } catch (e: Exception) { + Timber.e(e, "Failed to prefer linked account!") + false + } + @Throws(Exception::class) suspend fun getVerifyIssuers(): List? = withContext(Dispatchers.IO) { eduIdApi.getVerifyIssuers().body() diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoScreen.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoScreen.kt index 69c1ca7..ff46ce9 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoScreen.kt @@ -65,9 +65,12 @@ import nl.eduid.ui.EduIdTopAppBar import nl.eduid.ui.ExpandableVerifiedInfoField import nl.eduid.ui.InfoField import nl.eduid.ui.getDateTimeString +import nl.eduid.ui.getShortDateString import nl.eduid.ui.theme.ColorSupport_Blue_100 import nl.eduid.ui.theme.EduidAppAndroidTheme import nl.eduid.ui.theme.LinkAccountCard +import java.time.LocalDate +import java.time.ZoneOffset @Composable fun PersonalInfoRoute( @@ -218,7 +221,7 @@ fun PersonalInfoScreen( } ) uiState.verifiedFirstNameAccount?.let { firstNameAccount -> - addNameControl( + NameControl( value = firstNameAccount.givenName ?: personalInfo.name, label = stringResource(R.string.Profile_VerifiedGivenName_COPY), account = firstNameAccount, @@ -227,7 +230,7 @@ fun PersonalInfoScreen( } uiState.verifiedLastNameAccount?.let { lastNameAccount -> - addNameControl( + NameControl( value = lastNameAccount.familyName ?: personalInfo.selfAssertedName.familyName.orEmpty(), label = stringResource(R.string.Profile_VerifiedFamilyName_COPY), account = lastNameAccount, @@ -245,6 +248,19 @@ fun PersonalInfoScreen( } ) } + + uiState.verifiedDateOfBirthAccount?.let { dateOfBirthAccount -> + dateOfBirthAccount.dateOfBirth?.let { dateOfBirth -> + NameControl( + value = dateOfBirth.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli().getShortDateString(), + label = stringResource(R.string.Profile_VerifiedDateOfBirth_COPY), + account = dateOfBirthAccount, + openVerifiedInformation = openVerifiedInformation + ) + } + } + + // Email Text( style = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSecondary), @@ -315,15 +331,17 @@ private fun ColumnScope.Organisations( ) institutionAccounts.forEach { account -> ConnectionCard( - title = account.role ?: account.subjectId, + institutionName = account.institution, + role = account.role ?: account.subjectId, confirmedByInstitution = account, + isExpandable = true, openVerifiedInformation = openVerifiedInformation ) } } @Composable -private fun addNameControl(value: String, label: String, account: PersonalInfo.InstitutionAccount, openVerifiedInformation: () -> Unit) { +private fun NameControl(value: String, label: String, account: PersonalInfo.InstitutionAccount, openVerifiedInformation: () -> Unit) { ExpandableVerifiedInfoField( title = value, subtitle = label, diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoViewModel.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoViewModel.kt index cf4c676..f6d9854 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoViewModel.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/PersonalInfoViewModel.kt @@ -2,6 +2,7 @@ package nl.eduid.screens.personalinfo import android.content.Intent import android.net.Uri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,21 +18,27 @@ import nl.eduid.R import nl.eduid.di.assist.DataAssistant import nl.eduid.di.assist.SaveableResult import nl.eduid.di.assist.toErrorData +import nl.eduid.di.model.LinkedAccountUpdateRequest import nl.eduid.di.model.UserDetails import nl.eduid.di.model.mapToPersonalInfo import nl.eduid.flags.FeatureFlag import nl.eduid.flags.RuntimeBehavior +import nl.eduid.graphs.AccountLinked import javax.inject.Inject @HiltViewModel class PersonalInfoViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val assistant: DataAssistant, private val runtimeBehavior: RuntimeBehavior ) : ViewModel() { private val _errorData: MutableStateFlow = MutableStateFlow(null) private val _isProcessing: MutableStateFlow = MutableStateFlow(false) + private val _accountLinked: MutableStateFlow = MutableStateFlow(false) private val _linkUrl: MutableStateFlow = MutableStateFlow(null) + val isRegistrationFlow: Boolean = savedStateHandle.get(AccountLinked.isRegistrationFlowArg) ?: false + val uiState = assistant.observableDetails.map { when (it) { is SaveableResult.Success -> { @@ -41,6 +48,7 @@ class PersonalInfoViewModel @Inject constructor( } var verifiedFirstNameAccount: PersonalInfo.InstitutionAccount? = null var verifiedLastNameAccount: PersonalInfo.InstitutionAccount? = null + var verifiedDateOfBirthAccount: PersonalInfo.InstitutionAccount? = null // Search in linked internal accounts and then external account for (linkedAccount in personalInfo.linkedInternalAccounts + personalInfo.linkedExternalAccounts) { if (verifiedFirstNameAccount == null && linkedAccount.givenName != null && linkedAccount.givenName == personalInfo.selfAssertedName.givenName) { @@ -49,8 +57,11 @@ class PersonalInfoViewModel @Inject constructor( if (verifiedLastNameAccount == null && linkedAccount.familyName != null && linkedAccount.familyName == personalInfo.selfAssertedName.familyName) { verifiedLastNameAccount = linkedAccount } + if (verifiedDateOfBirthAccount == null && linkedAccount.dateOfBirth != null && linkedAccount.dateOfBirth == personalInfo.dateOfBirth) { + verifiedDateOfBirthAccount = linkedAccount + } } - // It is possible that there's a verified name, but it doesn't match the one in the profile. In this case we still need to show it + // It is possible that there's a verified name / birth date, but it doesn't match the one in the profile. In this case we still need to show it // So we go through the accounts once more, but do not check for matches anymore if (verifiedFirstNameAccount == null || verifiedLastNameAccount != null) { for (linkedAccount in (personalInfo.linkedInternalAccounts + personalInfo.linkedExternalAccounts)) { @@ -60,6 +71,9 @@ class PersonalInfoViewModel @Inject constructor( if (verifiedLastNameAccount == null && linkedAccount.familyName != null) { verifiedLastNameAccount = linkedAccount } + if (verifiedDateOfBirthAccount == null && linkedAccount.dateOfBirth != null) { + verifiedDateOfBirthAccount = linkedAccount + } } } @@ -67,7 +81,8 @@ class PersonalInfoViewModel @Inject constructor( isLoading = false, personalInfo = personalInfo, verifiedFirstNameAccount = verifiedFirstNameAccount, - verifiedLastNameAccount = verifiedLastNameAccount + verifiedLastNameAccount = verifiedLastNameAccount, + verifiedDateOfBirthAccount = verifiedDateOfBirthAccount ) } @@ -101,6 +116,11 @@ class PersonalInfoViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(3_000), initialValue = false, ) + val accountLinked = _accountLinked.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(3_000), + initialValue = false, + ) val linkUrl = _linkUrl.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(3_000), @@ -108,7 +128,7 @@ class PersonalInfoViewModel @Inject constructor( ) val hasLinkedInstitution = uiState.map { it.personalInfo.linkedInternalAccounts.isNotEmpty() || - it.personalInfo.linkedExternalAccounts.isNotEmpty() + it.personalInfo.linkedExternalAccounts.isNotEmpty() } val identityVerificationEnabled = runtimeBehavior.isFeatureEnabled(FeatureFlag.ENABLE_IDENTITY_VERIFICATION) @@ -143,12 +163,6 @@ class PersonalInfoViewModel @Inject constructor( fun clearErrorData() = _errorData.update { null } - fun removeConnection(institutionId: String) = viewModelScope.launch { - _isProcessing.update { true } - assistant.removeConnection(institutionId) - _isProcessing.update { false } - } - fun requestLinkUrl() = viewModelScope.launch { _isProcessing.update { true } val url = assistant.getStartLinkAccount() @@ -170,4 +184,41 @@ class PersonalInfoViewModel @Inject constructor( intent.data = Uri.parse(url) return intent } + + fun findLinkedAccount(personalInfo: PersonalInfo?, institutionId: String?): PersonalInfo.InstitutionAccount? { + val completeList = (personalInfo?.linkedInternalAccounts ?: listOf()) + (personalInfo?.linkedExternalAccounts ?: listOf()) + completeList.forEach { + if (it.institution == institutionId || it.subjectId == institutionId) { + return it + } + } + return completeList.firstOrNull() + } + + fun isFirstLinkedAccount(personalInfo: PersonalInfo): Boolean { + return personalInfo.linkedInternalAccounts.size + personalInfo.linkedExternalAccounts.size < 2 + } + + fun preferLinkedAccount(linkedAccount: PersonalInfo.InstitutionAccount) { + _isProcessing.update { true } + viewModelScope.launch { + if (assistant.preferLinkedAccount(linkedAccount.updateRequest)) { + _accountLinked.update { true } + } else { + _errorData.update { + ErrorData( + titleId = R.string.ExternalAccountLinkingError_Title_COPY, + messageId = R.string.ExternalAccountLinkingError_Subtitle_COPY + ) + } + } + _isProcessing.update { false } + } + } + + fun refreshPersonalInfo() { + viewModelScope.launch { + assistant.refreshDetails() + } + } } diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/UiState.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/UiState.kt index df8fb22..2510925 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/UiState.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/UiState.kt @@ -8,4 +8,5 @@ data class UiState( val isLoading: Boolean = false, val verifiedLastNameAccount: PersonalInfo.InstitutionAccount? = null, val verifiedFirstNameAccount: PersonalInfo.InstitutionAccount? = null, + val verifiedDateOfBirthAccount: PersonalInfo.InstitutionAccount? = null, ) \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/UiState.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/UiState.kt index d711c80..8a16a06 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/UiState.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/UiState.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.toImmutableList import nl.eduid.screens.personalinfo.PersonalInfo data class UiState( + val personalInfo: PersonalInfo? = null, val accounts: ImmutableList = emptyList().toImmutableList(), val isLoading: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoScreen.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoScreen.kt index 95586a7..61683e5 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoScreen.kt @@ -49,6 +49,7 @@ import nl.eduid.ui.getShortDateString import nl.eduid.ui.theme.ColorScale_Gray_200 import nl.eduid.ui.theme.EduidAppAndroidTheme import nl.eduid.util.normalizedIssuerName +import java.time.ZoneOffset @Composable fun VerifiedPersonalInfoRoute( @@ -82,6 +83,7 @@ fun VerifiedPersonalInfoRoute( } VerifiedPersonalInfoScreen( + personalInfo = uiState.personalInfo, accounts = uiState.accounts, isLoading = uiState.isLoading, onRemoveConnection = { subjectId -> @@ -113,6 +115,7 @@ fun VerifiedPersonalInfoRoute( @Composable fun VerifiedPersonalInfoScreen( + personalInfo: PersonalInfo?, accounts: ImmutableList, isLoading: Boolean, onRemoveConnection: (String) -> Unit, @@ -204,13 +207,26 @@ fun VerifiedPersonalInfoScreen( Spacer(Modifier.size(24.dp)) account.givenName?.let { VerifiedInfoField( - title = it, subtitle = stringResource(R.string.Profile_VerifiedGivenName_COPY) + title = it, + subtitle = stringResource(R.string.Profile_VerifiedGivenName_COPY), + isDefault = it == personalInfo?.confirmedName?.givenName ) + Spacer(Modifier.size(24.dp)) } account.familyName?.let { VerifiedInfoField( - title = it, subtitle = stringResource(R.string.Profile_VerifiedFamilyName_COPY) + title = it, + subtitle = stringResource(R.string.Profile_VerifiedFamilyName_COPY), + isDefault = it == personalInfo?.confirmedName?.familyName + ) + Spacer(Modifier.size(24.dp)) + } + account.dateOfBirth?.let { dateOfBirth -> + VerifiedInfoField( + title = dateOfBirth.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli().getShortDateString(), + subtitle = stringResource(R.string.Profile_VerifiedDateOfBirth_COPY), + isDefault = personalInfo?.dateOfBirth == dateOfBirth ) Spacer(Modifier.size(24.dp)) } @@ -219,7 +235,8 @@ fun VerifiedPersonalInfoScreen( title = it, subtitle = stringResource( id = R.string.YourVerifiedInformation_AtInstitution_COPY, account.institution.normalizedIssuerName() - ) + ), + isDefault = true ) Spacer(Modifier.size(24.dp)) } @@ -263,6 +280,7 @@ fun VerifiedPersonalInfoScreen( @Composable private fun Preview_VerifiedPersonalInfoScreen() = EduidAppAndroidTheme { VerifiedPersonalInfoScreen( + personalInfo = PersonalInfo.demoData(), accounts = PersonalInfo.generateInstitutionAccountList(), isLoading = false, onRemoveConnection = {}, diff --git a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoViewModel.kt b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoViewModel.kt index c0855cc..70748fa 100644 --- a/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoViewModel.kt +++ b/app/src/main/kotlin/nl/eduid/screens/personalinfo/verified/VerifiedPersonalInfoViewModel.kt @@ -16,6 +16,7 @@ import nl.eduid.di.assist.DataAssistant import nl.eduid.di.assist.SaveableResult import nl.eduid.di.assist.toErrorData import nl.eduid.di.model.mapToInstitutionAccount +import nl.eduid.di.model.mapToPersonalInfo import javax.inject.Inject @HiltViewModel @@ -33,6 +34,7 @@ class VerifiedPersonalInfoViewModel @Inject constructor( _errorData.emit(details.saveError.toErrorData()) } UiState( + personalInfo = details.data.mapToPersonalInfo(), isLoading = false, accounts = (details.data.linkedAccounts.mapNotNull { it.mapToInstitutionAccount() } + details.data.externalLinkedAccounts.mapNotNull { it.mapToInstitutionAccount() }).toImmutableList() diff --git a/app/src/main/kotlin/nl/eduid/ui/Buttons.kt b/app/src/main/kotlin/nl/eduid/ui/Buttons.kt index a2e0a57..ad1a942 100644 --- a/app/src/main/kotlin/nl/eduid/ui/Buttons.kt +++ b/app/src/main/kotlin/nl/eduid/ui/Buttons.kt @@ -37,12 +37,16 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import nl.eduid.R import nl.eduid.ui.theme.ColorAlertRed +import nl.eduid.ui.theme.ColorMain_Green_400 import nl.eduid.ui.theme.EduidAppAndroidTheme @Composable fun PrimaryButton( - text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + text: String, onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, buttonBackgroundColor: Color = MaterialTheme.colorScheme.onSecondary, + buttonTextColor: Color = Color.White ) = Button( onClick = onClick, enabled = enabled, @@ -57,7 +61,9 @@ fun PrimaryButton( Text( text = text, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, color = buttonTextColor + ), ) } diff --git a/app/src/main/kotlin/nl/eduid/ui/ConnectionCard.kt b/app/src/main/kotlin/nl/eduid/ui/ConnectionCard.kt index c67123f..1b72c04 100644 --- a/app/src/main/kotlin/nl/eduid/ui/ConnectionCard.kt +++ b/app/src/main/kotlin/nl/eduid/ui/ConnectionCard.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -54,15 +55,18 @@ import nl.eduid.ui.theme.BlueButton import nl.eduid.ui.theme.ColorAlertRed import nl.eduid.ui.theme.ColorScale_Gray_500 import nl.eduid.ui.theme.ColorSupport_Blue_100 +import nl.eduid.ui.theme.ColorSupport_Blue_400 import nl.eduid.ui.theme.EduidAppAndroidTheme import nl.eduid.util.normalizedIssuerName import java.util.Locale @Composable fun ConnectionCard( - title: String, + institutionName: String, + role: String, confirmedByInstitution: PersonalInfo.InstitutionAccount, modifier: Modifier = Modifier, + isExpandable: Boolean, expandedPreview: Boolean = false, openVerifiedInformation: () -> Unit = {}, ) { @@ -77,10 +81,21 @@ fun ConnectionCard( containerColor = containerColor, trailingIconColor = MaterialTheme.colorScheme.onSurface ), - leadingContent = {}, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(R.drawable.ic_school_building), + tint = ColorSupport_Blue_400, + contentDescription = null + ) + } + }, headlineContent = { Text( - text = title, + text = institutionName, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -90,10 +105,7 @@ fun ConnectionCard( supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = stringResource( - id = R.string.YourVerifiedInformation_AtInstitution_COPY, - confirmedByInstitution.institution - ), + text = role, modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), @@ -134,16 +146,18 @@ fun ConnectionCard( } }, trailingContent = { - if (isExpanded) { - Icon( - imageVector = Icons.Outlined.KeyboardArrowUp, - contentDescription = "", - ) - } else { - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = "", - ) + if (isExpandable) { + if (isExpanded) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowUp, + contentDescription = "", + ) + } else { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = "", + ) + } } }, modifier = modifier @@ -153,7 +167,9 @@ fun ConnectionCard( width = 2.dp ) .clickable { - isExpanded = !isExpanded + if (isExpandable) { + isExpanded = !isExpanded + } } ) } @@ -286,11 +302,15 @@ private fun InstitutionInfoBlock( private fun Preview_ConnectionCard() = EduidAppAndroidTheme { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ConnectionCard( - title = "Librarian", + institutionName = "University of Amsterdam", + role = "Librarian", + isExpandable = false, confirmedByInstitution = generateInstitutionAccountList()[0], ) ConnectionCard( - title = "Librarian", + institutionName = "University of Amsterdam", + role = "Librarian", + isExpandable = true, confirmedByInstitution = generateInstitutionAccountList()[0], expandedPreview = true, ) diff --git a/app/src/main/kotlin/nl/eduid/ui/InfoField.kt b/app/src/main/kotlin/nl/eduid/ui/InfoField.kt index 327e36f..11df141 100644 --- a/app/src/main/kotlin/nl/eduid/ui/InfoField.kt +++ b/app/src/main/kotlin/nl/eduid/ui/InfoField.kt @@ -236,6 +236,7 @@ fun AddSecurityField( fun VerifiedInfoField( title: String, subtitle: String, + isDefault: Boolean = false, modifier: Modifier = Modifier, ) { ListItem( @@ -267,11 +268,13 @@ fun VerifiedInfoField( ) }, trailingContent = { - Icon( - painter = painterResource(R.drawable.homepage_info_icon), - tint = MaterialTheme.colorScheme.onSecondary, - contentDescription = "", - ) + if (isDefault) { + Icon( + painter = painterResource(R.drawable.homepage_info_icon), + tint = MaterialTheme.colorScheme.onSecondary, + contentDescription = "", + ) + } }, modifier = modifier .border( diff --git a/app/src/main/res/drawable/ic_school_building.xml b/app/src/main/res/drawable/ic_school_building.xml new file mode 100644 index 0000000..6914cb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_school_building.xml @@ -0,0 +1,24 @@ + + + + + + + +