diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/config/SuccessUIConfig.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/config/SuccessUIConfig.kt index d40c041c..2caa247b 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/config/SuccessUIConfig.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/config/SuccessUIConfig.kt @@ -17,8 +17,10 @@ package eu.europa.ec.commonfeature.config import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color import com.google.gson.Gson import com.google.gson.GsonBuilder +import eu.europa.ec.resourceslogic.theme.values.ThemeColors import eu.europa.ec.uilogic.config.ConfigNavigation import eu.europa.ec.uilogic.config.NavigationType import eu.europa.ec.uilogic.serializer.UiSerializable @@ -26,7 +28,7 @@ import eu.europa.ec.uilogic.serializer.UiSerializableParser import eu.europa.ec.uilogic.serializer.adapter.SerializableTypeAdapter data class SuccessUIConfig( - val header: String?, + val headerConfig: HeaderConfig?, val content: String, val imageConfig: ImageConfig, val buttonConfig: List, @@ -36,6 +38,7 @@ data class SuccessUIConfig( data class ImageConfig( val type: Type, @DrawableRes val drawableRes: Int? = null, + val tint: Color = ThemeColors.success, val contentDescription: String? = null ) { enum class Type { @@ -53,6 +56,11 @@ data class SuccessUIConfig( } } + data class HeaderConfig( + val title: String, + val color: Color = ThemeColors.success + ) + companion object Parser : UiSerializableParser { override val serializedKeyName = "successConfig" override fun provideParser(): Gson { diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/model/DocumentTypeUi.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/model/DocumentTypeUi.kt index 90898dc8..435f200e 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/model/DocumentTypeUi.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/model/DocumentTypeUi.kt @@ -21,12 +21,17 @@ import eu.europa.ec.corelogic.model.DocumentIdentifier import eu.europa.ec.corelogic.model.isSupported import eu.europa.ec.corelogic.model.toDocumentIdentifier import eu.europa.ec.eudi.iso18013.transfer.RequestDocument -import eu.europa.ec.eudi.wallet.document.IssuedDocument +import eu.europa.ec.eudi.wallet.document.Document +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider +enum class DocumentUiIssuanceState { + Issued, Pending, Failed +} + data class DocumentUi( - val documentId: String, + val documentIssuanceState: DocumentUiIssuanceState, val documentName: String, val documentIdentifier: DocumentIdentifier, val documentExpirationDateFormatted: String, @@ -34,6 +39,7 @@ data class DocumentUi( val documentImage: String, val documentDetails: List, val userFullName: String? = null, + val documentId: DocumentId, ) fun DocumentIdentifier.toUiName(resourceProvider: ResourceProvider): String { @@ -46,7 +52,7 @@ fun DocumentIdentifier.toUiName(resourceProvider: ResourceProvider): String { } } -fun IssuedDocument.toUiName(resourceProvider: ResourceProvider): String { +fun Document.toUiName(resourceProvider: ResourceProvider): String { val docIdentifier = this.toDocumentIdentifier() return docIdentifier.toUiName( fallbackDocName = this.name, diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/document_details/transformer/DocumentDetailsTransformer.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/document_details/transformer/DocumentDetailsTransformer.kt index 40bb8411..9d6c3af5 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/document_details/transformer/DocumentDetailsTransformer.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/document_details/transformer/DocumentDetailsTransformer.kt @@ -19,6 +19,7 @@ package eu.europa.ec.commonfeature.ui.document_details.transformer import eu.europa.ec.businesslogic.util.toDateFormatted import eu.europa.ec.businesslogic.util.toList import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.model.toUiName import eu.europa.ec.commonfeature.ui.document_details.model.DocumentDetailsUi import eu.europa.ec.commonfeature.ui.document_details.model.DocumentJsonKeys @@ -92,7 +93,8 @@ object DocumentDetailsTransformer { documentHasExpired = docHasExpired, documentImage = documentImage, documentDetails = detailsItems, - userFullName = extractFullNameFromDocumentOrEmpty(document) + userFullName = extractFullNameFromDocumentOrEmpty(document), + documentIssuanceState = DocumentUiIssuanceState.Issued, ) } diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/pin/PinViewModel.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/pin/PinViewModel.kt index a4c56a60..519bb03a 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/pin/PinViewModel.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/pin/PinViewModel.kt @@ -366,7 +366,9 @@ class PinViewModel( mapOf( SuccessUIConfig.serializedKeyName to uiSerializer.toBase64( SuccessUIConfig( - header = resourceProvider.getString(R.string.quick_pin_success_title), + headerConfig = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.quick_pin_success_title), + ), content = when (pinFlow) { PinFlow.CREATE -> resourceProvider.getString(R.string.quick_pin_create_success_subtitle) PinFlow.UPDATE -> resourceProvider.getString(R.string.quick_pin_change_success_subtitle) diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/success/SuccessScreen.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/success/SuccessScreen.kt index ee374585..1cd00336 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/success/SuccessScreen.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/ui/success/SuccessScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -41,7 +42,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController import eu.europa.ec.commonfeature.config.SuccessUIConfig +import eu.europa.ec.resourceslogic.theme.values.ThemeColors import eu.europa.ec.resourceslogic.theme.values.success +import eu.europa.ec.uilogic.component.AppIcons import eu.europa.ec.uilogic.component.content.ContentScreen import eu.europa.ec.uilogic.component.content.ContentTitle import eu.europa.ec.uilogic.component.content.ScreenNavigateAction @@ -127,9 +130,9 @@ private fun SuccessScreenView( ) { Column(modifier = Modifier.fillMaxWidth()) { ContentTitle( - title = state.successConfig.header, + title = state.successConfig.headerConfig?.title, titleStyle = MaterialTheme.typography.headlineSmall.copy( - color = MaterialTheme.colorScheme.success + color = state.successConfig.headerConfig?.color ?: Color.Unspecified ), subtitle = state.successConfig.content, ) @@ -156,8 +159,11 @@ private fun SuccessScreenView( // Image imageConfig.type == SuccessUIConfig.ImageConfig.Type.DRAWABLE && imageConfig.drawableRes != null -> { Image( + modifier = Modifier.fillMaxWidth(0.25f), painter = painterResource(id = imageConfig.drawableRes), - contentDescription = imageConfig.contentDescription + contentDescription = imageConfig.contentDescription, + colorFilter = ColorFilter.tint(imageConfig.tint), + contentScale = ContentScale.FillWidth ) } } @@ -225,13 +231,15 @@ private fun ButtonRow(text: String) { @ThemeModePreviews @Composable -private fun SuccessPreview() { +private fun SuccessDefaultPreview() { PreviewTheme { SuccessScreenView( state = State( successConfig = SuccessUIConfig( - header = "Success", - content = "", + headerConfig = SuccessUIConfig.HeaderConfig( + title = "Success", + ), + content = "Subtitle", imageConfig = SuccessUIConfig.ImageConfig( type = SuccessUIConfig.ImageConfig.Type.DEFAULT ), @@ -255,4 +263,44 @@ private fun SuccessPreview() { paddingValues = PaddingValues(16.dp) ) } +} + +@ThemeModePreviews +@Composable +private fun SuccessDrawablePreview() { + PreviewTheme { + SuccessScreenView( + state = State( + successConfig = SuccessUIConfig( + headerConfig = SuccessUIConfig.HeaderConfig( + title = "In Progress", + color = ThemeColors.warning + ), + content = "Subtitle", + imageConfig = SuccessUIConfig.ImageConfig( + type = SuccessUIConfig.ImageConfig.Type.DRAWABLE, + drawableRes = AppIcons.ClockTimer.resourceId, + tint = ThemeColors.warning, + contentDescription = "contentDescription" + ), + buttonConfig = listOf( + SuccessUIConfig.ButtonConfig( + text = "back", + style = SuccessUIConfig.ButtonConfig.Style.PRIMARY, + navigation = ConfigNavigation( + navigationType = NavigationType.PopTo(StartupScreens.Splash), + ) + ) + ), + onBackScreenToNavigate = ConfigNavigation( + navigationType = NavigationType.PopTo(StartupScreens.Splash), + ), + ) + ), + effectFlow = Channel().receiveAsFlow(), + onEventSent = {}, + onNavigationRequested = {}, + paddingValues = PaddingValues(16.dp) + ) + } } \ No newline at end of file diff --git a/common-feature/src/main/java/eu/europa/ec/commonfeature/util/TestsData.kt b/common-feature/src/main/java/eu/europa/ec/commonfeature/util/TestsData.kt index e82ac5e7..06e6b348 100644 --- a/common-feature/src/main/java/eu/europa/ec/commonfeature/util/TestsData.kt +++ b/common-feature/src/main/java/eu/europa/ec/commonfeature/util/TestsData.kt @@ -19,6 +19,7 @@ package eu.europa.ec.commonfeature.util import androidx.annotation.VisibleForTesting import eu.europa.ec.commonfeature.model.DocumentOptionItemUi import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.ui.document_details.model.DocumentDetailsUi import eu.europa.ec.commonfeature.ui.request.Event import eu.europa.ec.commonfeature.ui.request.model.DocumentItemDomainPayload @@ -222,6 +223,7 @@ object TestsData { documentHasExpired = mockedDocumentHasExpired, documentImage = "", documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued, ) val mockedBasicPidUi = mockedFullPidUi.copy( @@ -294,6 +296,7 @@ object TestsData { documentHasExpired = mockedDocumentHasExpired, documentImage = "", documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued, ) val mockedBasicMdlUi = mockedFullMdlUi.copy( diff --git a/core-logic/src/main/java/eu/europa/ec/corelogic/controller/WalletCoreDocumentsController.kt b/core-logic/src/main/java/eu/europa/ec/corelogic/controller/WalletCoreDocumentsController.kt index 290ddd36..52743ca5 100644 --- a/core-logic/src/main/java/eu/europa/ec/corelogic/controller/WalletCoreDocumentsController.kt +++ b/core-logic/src/main/java/eu/europa/ec/corelogic/controller/WalletCoreDocumentsController.kt @@ -19,12 +19,18 @@ package eu.europa.ec.corelogic.controller import eu.europa.ec.authenticationlogic.controller.authentication.DeviceAuthenticationResult import eu.europa.ec.authenticationlogic.model.BiometricCrypto import eu.europa.ec.businesslogic.extension.safeAsync +import eu.europa.ec.corelogic.model.DeferredDocumentData import eu.europa.ec.corelogic.model.DocType import eu.europa.ec.corelogic.model.DocumentIdentifier import eu.europa.ec.eudi.wallet.EudiWallet +import eu.europa.ec.eudi.wallet.document.DeferredDocument import eu.europa.ec.eudi.wallet.document.DeleteDocumentResult +import eu.europa.ec.eudi.wallet.document.Document +import eu.europa.ec.eudi.wallet.document.Document.State +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.eudi.wallet.document.sample.LoadSampleResult +import eu.europa.ec.eudi.wallet.issue.openid4vci.DeferredIssueResult import eu.europa.ec.eudi.wallet.issue.openid4vci.IssueEvent import eu.europa.ec.eudi.wallet.issue.openid4vci.Offer import eu.europa.ec.eudi.wallet.issue.openid4vci.OfferResult @@ -47,6 +53,8 @@ enum class IssuanceMethod { sealed class IssueDocumentPartialState { data class Success(val documentId: String) : IssueDocumentPartialState() + data class DeferredSuccess(val deferredDocuments: Map) : + IssueDocumentPartialState() data class Failure(val errorMessage: String) : IssueDocumentPartialState() data class UserAuthRequired( @@ -57,6 +65,9 @@ sealed class IssueDocumentPartialState { sealed class IssueDocumentsPartialState { data class Success(val documentIds: List) : IssueDocumentsPartialState() + data class DeferredSuccess(val deferredDocuments: Map) : + IssueDocumentsPartialState() + data class PartialSuccess( val documentIds: List, val nonIssuedDocuments: Map @@ -89,6 +100,25 @@ sealed class ResolveDocumentOfferPartialState { data class Failure(val errorMessage: String) : ResolveDocumentOfferPartialState() } +sealed class IssueDeferredDocumentPartialState { + data class Issued( + val deferredDocumentData: DeferredDocumentData, + ) : IssueDeferredDocumentPartialState() + + data class NotReady( + val deferredDocumentData: DeferredDocumentData + ) : IssueDeferredDocumentPartialState() + + data class Failed( + val documentId: DocumentId, + val errorMessage: String + ) : IssueDeferredDocumentPartialState() + + data class Expired( + val documentId: DocumentId, + ) : IssueDeferredDocumentPartialState() +} + /** * Controller for interacting with internal local storage of Core for CRUD operations on documents * */ @@ -106,11 +136,13 @@ interface WalletCoreDocumentsController { /** * @return All the documents from the Database. * */ - fun getAllDocuments(): List + fun getAllDocuments(): List + + fun getAllIssuedDocuments(): List fun getAllDocumentsByType(documentIdentifier: DocumentIdentifier): List - fun getDocumentById(id: String): IssuedDocument? + fun getDocumentById(documentId: DocumentId): Document? fun getMainPidDocument(): IssuedDocument? @@ -131,6 +163,10 @@ interface WalletCoreDocumentsController { fun deleteAllDocuments(mainPidDocumentId: String): Flow fun resolveDocumentOffer(offerUri: String): Flow + + fun issueDeferredDocument(docId: DocumentId): Flow + + fun resumeOpenId4VciWithAuthorization(uri: String) } class WalletCoreDocumentsControllerImpl( @@ -144,6 +180,10 @@ class WalletCoreDocumentsControllerImpl( private val documentErrorMessage get() = resourceProvider.getString(R.string.issuance_generic_error) + private val openId4VciManager by lazy { + eudiWallet.createOpenId4VciManager() + } + override fun loadSampleData(sampleDataByteArray: ByteArray): Flow = flow { when (val result = eudiWallet.loadSampleData(sampleDataByteArray)) { @@ -174,13 +214,18 @@ class WalletCoreDocumentsControllerImpl( AddSampleDataPartialState.Failure(it.localizedMessage ?: genericErrorMessage) } - override fun getAllDocuments(): List = eudiWallet.getDocuments() + override fun getAllDocuments(): List = eudiWallet.getAllDocuments() + .filter { it.state != State.UNSIGNED } + + override fun getAllIssuedDocuments(): List = eudiWallet.getDocuments() override fun getAllDocumentsByType(documentIdentifier: DocumentIdentifier): List = - getAllDocuments().filter { it.docType == documentIdentifier.docType } + getAllDocuments() + .filterIsInstance() + .filter { it.docType == documentIdentifier.docType } - override fun getDocumentById(id: String): IssuedDocument? { - return eudiWallet.getDocumentById(documentId = id) as? IssuedDocument + override fun getDocumentById(documentId: DocumentId): Document? { + return eudiWallet.getDocumentById(documentId = documentId) } override fun getMainPidDocument(): IssuedDocument? = @@ -220,6 +265,12 @@ class WalletCoreDocumentsControllerImpl( response.documentIds.first() ) ) + + is IssueDocumentsPartialState.DeferredSuccess -> emit( + IssueDocumentPartialState.DeferredSuccess( + response.deferredDocuments + ) + ) } } } @@ -233,9 +284,9 @@ class WalletCoreDocumentsControllerImpl( txCode: String? ): Flow = callbackFlow { - eudiWallet.issueDocumentByOfferUri( + openId4VciManager.issueDocumentByOfferUri( offerUri = offerUri, - onEvent = issuanceCallback(), + onIssueEvent = issuanceCallback(), txCode = txCode ) awaitClose() @@ -268,7 +319,7 @@ class WalletCoreDocumentsControllerImpl( override fun deleteAllDocuments(mainPidDocumentId: String): Flow = flow { - val allDocuments = eudiWallet.getDocuments() + val allDocuments = getAllDocuments() val mainPidDocument = getMainPidDocument() mainPidDocument?.let { @@ -326,14 +377,15 @@ class WalletCoreDocumentsControllerImpl( override fun resolveDocumentOffer(offerUri: String): Flow = callbackFlow { - eudiWallet.resolveDocumentOffer( + openId4VciManager.resolveDocumentOffer( offerUri = offerUri, - onResult = { offerResult -> + onResolvedOffer = { offerResult -> when (offerResult) { is OfferResult.Failure -> { trySendBlocking( ResolveDocumentOfferPartialState.Failure( - errorMessage = offerResult.cause.message ?: genericErrorMessage + errorMessage = offerResult.cause.localizedMessage + ?: genericErrorMessage ) ) } @@ -356,12 +408,83 @@ class WalletCoreDocumentsControllerImpl( ) } + override fun issueDeferredDocument(docId: DocumentId): Flow = + callbackFlow { + (getDocumentById(docId) as? DeferredDocument)?.let { deferredDoc -> + openId4VciManager.issueDeferredDocument( + deferredDocument = deferredDoc, + executor = null, + onIssueResult = { deferredIssuanceResult -> + when (deferredIssuanceResult) { + is DeferredIssueResult.DocumentFailed -> { + trySendBlocking( + IssueDeferredDocumentPartialState.Failed( + documentId = deferredIssuanceResult.documentId, + errorMessage = deferredIssuanceResult.cause.localizedMessage + ?: documentErrorMessage + ) + ) + } + + is DeferredIssueResult.DocumentIssued -> { + trySendBlocking( + IssueDeferredDocumentPartialState.Issued( + DeferredDocumentData( + documentId = deferredIssuanceResult.documentId, + docType = deferredIssuanceResult.docType, + docName = deferredIssuanceResult.name + ) + ) + ) + } + + is DeferredIssueResult.DocumentNotReady -> { + trySendBlocking( + IssueDeferredDocumentPartialState.NotReady( + DeferredDocumentData( + documentId = deferredIssuanceResult.documentId, + docType = deferredIssuanceResult.docType, + docName = deferredIssuanceResult.name + ) + ) + ) + } + + is DeferredIssueResult.DocumentExpired -> { + trySendBlocking( + IssueDeferredDocumentPartialState.Expired( + documentId = deferredIssuanceResult.documentId + ) + ) + } + } + } + ) + } ?: trySendBlocking( + IssueDeferredDocumentPartialState.Failed( + documentId = docId, + errorMessage = documentErrorMessage + ) + ) + + awaitClose() + }.safeAsync { + IssueDeferredDocumentPartialState.Failed( + documentId = docId, + errorMessage = it.localizedMessage ?: genericErrorMessage + ) + } + + override fun resumeOpenId4VciWithAuthorization(uri: String) { + openId4VciManager.resumeWithAuthorization(uri) + } + private fun issueDocumentWithOpenId4VCI(documentType: DocType): Flow = callbackFlow { - eudiWallet.issueDocumentByDocType( + openId4VciManager.issueDocumentByDocType( docType = documentType, - onEvent = issuanceCallback() + onIssueEvent = issuanceCallback() ) awaitClose() @@ -375,8 +498,9 @@ class WalletCoreDocumentsControllerImpl( private fun ProducerScope.issuanceCallback(): OpenId4VciManager.OnIssueEvent { var totalDocumentsToBeIssued = 0 - val nonIssuedDocuments: MutableMap = mutableMapOf() - val issuedDocuments: MutableMap = mutableMapOf() + val nonIssuedDocuments: MutableMap = mutableMapOf() + val deferredDocuments: MutableMap = mutableMapOf() + val issuedDocuments: MutableMap = mutableMapOf() val listener = OpenId4VciManager.OnIssueEvent { event -> when (event) { @@ -407,6 +531,11 @@ class WalletCoreDocumentsControllerImpl( is IssueEvent.Finished -> { + if (deferredDocuments.isNotEmpty()) { + trySendBlocking(IssueDocumentsPartialState.DeferredSuccess(deferredDocuments)) + return@OnIssueEvent + } + if (event.issuedDocuments.isEmpty()) { trySendBlocking( IssueDocumentsPartialState.Failure( @@ -442,7 +571,7 @@ class WalletCoreDocumentsControllerImpl( } is IssueEvent.DocumentDeferred -> { - // TODO Not yet implemented + deferredDocuments[event.documentId] = event.docType } } } diff --git a/core-logic/src/main/java/eu/europa/ec/corelogic/model/DeferredDocumentData.kt b/core-logic/src/main/java/eu/europa/ec/corelogic/model/DeferredDocumentData.kt new file mode 100644 index 00000000..341e1d68 --- /dev/null +++ b/core-logic/src/main/java/eu/europa/ec/corelogic/model/DeferredDocumentData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the EUPL, Version 1.2 or - as soon they will be approved by the European + * Commission - subsequent versions of the EUPL (the "Licence"); You may not use this work + * except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the Licence for the specific language + * governing permissions and limitations under the Licence. + */ + +package eu.europa.ec.corelogic.model + +import eu.europa.ec.eudi.wallet.document.DocumentId + +data class DeferredDocumentData( + val documentId: DocumentId, + val docType: DocType, + val docName: String, +) \ No newline at end of file diff --git a/core-logic/src/main/java/eu/europa/ec/corelogic/model/DocumentIdentifier.kt b/core-logic/src/main/java/eu/europa/ec/corelogic/model/DocumentIdentifier.kt index 970915d2..87b035de 100644 --- a/core-logic/src/main/java/eu/europa/ec/corelogic/model/DocumentIdentifier.kt +++ b/core-logic/src/main/java/eu/europa/ec/corelogic/model/DocumentIdentifier.kt @@ -17,6 +17,7 @@ package eu.europa.ec.corelogic.model import eu.europa.ec.eudi.iso18013.transfer.RequestDocument +import eu.europa.ec.eudi.wallet.document.Document import eu.europa.ec.eudi.wallet.document.IssuedDocument typealias DocType = String @@ -81,8 +82,8 @@ fun DocType.toDocumentIdentifier(): DocumentIdentifier = when (this) { ) } -fun IssuedDocument.toDocumentIdentifier(): DocumentIdentifier { - val nameSpace = this.nameSpaces.keys.first() +fun Document.toDocumentIdentifier(): DocumentIdentifier { + val nameSpace = (this as? IssuedDocument)?.nameSpaces?.keys?.firstOrNull().orEmpty() val docType = this.docType return createDocumentIdentifier(nameSpace, docType) diff --git a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/interactor/DashboardInteractor.kt b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/interactor/DashboardInteractor.kt index 5017c236..6aa76ddf 100644 --- a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/interactor/DashboardInteractor.kt +++ b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/interactor/DashboardInteractor.kt @@ -22,33 +22,94 @@ import eu.europa.ec.businesslogic.config.ConfigLogic import eu.europa.ec.businesslogic.extension.safeAsync import eu.europa.ec.businesslogic.util.toDateFormatted import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.model.toUiName import eu.europa.ec.commonfeature.ui.document_details.model.DocumentJsonKeys import eu.europa.ec.commonfeature.util.documentHasExpired import eu.europa.ec.commonfeature.util.extractValueFromDocumentOrEmpty import eu.europa.ec.corelogic.config.WalletCoreConfig +import eu.europa.ec.corelogic.controller.DeleteDocumentPartialState +import eu.europa.ec.corelogic.controller.IssueDeferredDocumentPartialState import eu.europa.ec.corelogic.controller.WalletCoreDocumentsController +import eu.europa.ec.corelogic.model.DeferredDocumentData +import eu.europa.ec.corelogic.model.DocType import eu.europa.ec.corelogic.model.toDocumentIdentifier +import eu.europa.ec.dashboardfeature.model.UserInfo +import eu.europa.ec.eudi.wallet.document.Document +import eu.europa.ec.eudi.wallet.document.DocumentId +import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext -sealed class DashboardInteractorPartialState { +sealed class DashboardInteractorGetDocumentsPartialState { data class Success( - val documents: List, + val documentsUi: List, + val mainPid: IssuedDocument?, val userFirstName: String, val userBase64Portrait: String, - ) : DashboardInteractorPartialState() + ) : DashboardInteractorGetDocumentsPartialState() - data class Failure(val error: String) : DashboardInteractorPartialState() + data class Failure(val error: String) : DashboardInteractorGetDocumentsPartialState() +} + +sealed class DashboardInteractorDeleteDocumentPartialState { + data object SingleDocumentDeleted : DashboardInteractorDeleteDocumentPartialState() + data object AllDocumentsDeleted : DashboardInteractorDeleteDocumentPartialState() + data class Failure(val errorMessage: String) : + DashboardInteractorDeleteDocumentPartialState() +} + +sealed class DashboardInteractorRetryIssuingDeferredDocumentPartialState { + data class Success( + val deferredDocumentData: DeferredDocumentData + ) : DashboardInteractorRetryIssuingDeferredDocumentPartialState() + + data class NotReady( + val deferredDocumentData: DeferredDocumentData + ) : DashboardInteractorRetryIssuingDeferredDocumentPartialState() + + data class Failure( + val documentId: DocumentId, + val errorMessage: String, + ) : DashboardInteractorRetryIssuingDeferredDocumentPartialState() + + data class Expired( + val documentId: DocumentId, + ) : DashboardInteractorRetryIssuingDeferredDocumentPartialState() +} + +sealed class DashboardInteractorRetryIssuingDeferredDocumentsPartialState { + data class Result( + val successfullyIssuedDeferredDocuments: List, + val failedIssuedDeferredDocuments: List, + ) : DashboardInteractorRetryIssuingDeferredDocumentsPartialState() + + data class Failure( + val errorMessage: String, + ) : DashboardInteractorRetryIssuingDeferredDocumentsPartialState() } interface DashboardInteractor { - fun getDocuments(): Flow + fun getDocuments(): Flow fun isBleAvailable(): Boolean fun isBleCentralClientModeEnabled(): Boolean fun getAppVersion(): String + fun deleteDocument( + documentId: String + ): Flow + + fun tryIssuingDeferredDocumentsFlow( + deferredDocuments: Map, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Flow } class DashboardInteractorImpl( @@ -70,62 +131,213 @@ class DashboardInteractorImpl( override fun isBleCentralClientModeEnabled(): Boolean = walletCoreConfig.config.bleCentralClientModeEnabled - override fun getDocuments(): Flow = flow { + override fun getDocuments(): Flow = flow { var userFirstName = "" var userImage = "" val documents = walletCoreDocumentsController.getAllDocuments() val mainPid = walletCoreDocumentsController.getMainPidDocument() val documentsUi = documents.map { document -> - - var documentExpirationDate = extractValueFromDocumentOrEmpty( - document = document, - key = DocumentJsonKeys.EXPIRY_DATE - ) - - val docHasExpired = documentHasExpired(documentExpirationDate) - - documentExpirationDate = if (documentExpirationDate.isNotBlank()) { - documentExpirationDate.toDateFormatted().toString() - } else { - resourceProvider.getString(R.string.dashboard_document_no_expiration_found) - } + val (documentUi, userInfo) = document.toDocumentUiAndUserInfo(mainPid) if (userFirstName.isBlank()) { - userFirstName = extractValueFromDocumentOrEmpty( - document = mainPid ?: document, - key = DocumentJsonKeys.FIRST_NAME - ) + userFirstName = userInfo.userFirstName } - if (userImage.isBlank()) { - userImage = extractValueFromDocumentOrEmpty( - document = document, - key = DocumentJsonKeys.PORTRAIT - ) + userImage = userInfo.userBase64Portrait } - return@map DocumentUi( - documentId = document.id, - documentName = document.toUiName(resourceProvider), - documentIdentifier = document.toDocumentIdentifier(), - documentImage = "", - documentExpirationDateFormatted = documentExpirationDate, - documentHasExpired = docHasExpired, - documentDetails = emptyList() - ) + return@map documentUi } emit( - DashboardInteractorPartialState.Success( - documents = documentsUi, + DashboardInteractorGetDocumentsPartialState.Success( + documentsUi = documentsUi, + mainPid = mainPid, userFirstName = userFirstName, userBase64Portrait = userImage ) ) }.safeAsync { - DashboardInteractorPartialState.Failure( + DashboardInteractorGetDocumentsPartialState.Failure( error = it.localizedMessage ?: genericErrorMsg ) } override fun getAppVersion(): String = configLogic.appVersion + + override fun deleteDocument( + documentId: String, + ): Flow = + flow { + walletCoreDocumentsController.deleteDocument(documentId).collect { response -> + when (response) { + is DeleteDocumentPartialState.Failure -> { + emit( + DashboardInteractorDeleteDocumentPartialState.Failure( + errorMessage = response.errorMessage + ) + ) + } + + is DeleteDocumentPartialState.Success -> { + if (walletCoreDocumentsController.getAllDocuments().isEmpty()) { + emit(DashboardInteractorDeleteDocumentPartialState.AllDocumentsDeleted) + } else + emit(DashboardInteractorDeleteDocumentPartialState.SingleDocumentDeleted) + } + } + } + }.safeAsync { + DashboardInteractorDeleteDocumentPartialState.Failure( + errorMessage = it.localizedMessage ?: genericErrorMsg + ) + } + + + override fun tryIssuingDeferredDocumentsFlow( + deferredDocuments: Map, + dispatcher: CoroutineDispatcher, + ): Flow = flow { + + val successResults: MutableList = mutableListOf() + val failedResults: MutableList = mutableListOf() + + withContext(dispatcher) { + val allJobs = deferredDocuments.keys.map { deferredDocumentId -> + async { + tryIssuingDeferredDocumentSuspend(deferredDocumentId) + } + } + + allJobs.forEach { job -> + when (val result = job.await()) { + is DashboardInteractorRetryIssuingDeferredDocumentPartialState.Failure -> { + failedResults.add(result.documentId) + } + + is DashboardInteractorRetryIssuingDeferredDocumentPartialState.Success -> { + successResults.add(result.deferredDocumentData) + } + + is DashboardInteractorRetryIssuingDeferredDocumentPartialState.NotReady -> {} + + is DashboardInteractorRetryIssuingDeferredDocumentPartialState.Expired -> { + deleteDocument(result.documentId) + } + } + } + } + + emit( + DashboardInteractorRetryIssuingDeferredDocumentsPartialState.Result( + successfullyIssuedDeferredDocuments = successResults, + failedIssuedDeferredDocuments = failedResults + ) + ) + + }.safeAsync { + DashboardInteractorRetryIssuingDeferredDocumentsPartialState.Failure( + errorMessage = it.localizedMessage ?: genericErrorMsg + ) + } + + private fun Document.toDocumentUiAndUserInfo(mainPid: IssuedDocument?): Pair { + when (this) { + is IssuedDocument -> { + var documentExpirationDate = extractValueFromDocumentOrEmpty( + document = this, + key = DocumentJsonKeys.EXPIRY_DATE + ) + + val docHasExpired = documentHasExpired(documentExpirationDate) + + documentExpirationDate = if (documentExpirationDate.isNotBlank()) { + documentExpirationDate.toDateFormatted().toString() + } else { + resourceProvider.getString(R.string.dashboard_document_no_expiration_found) + } + + val userFirstName = extractValueFromDocumentOrEmpty( + document = mainPid ?: this, + key = DocumentJsonKeys.FIRST_NAME + ) + + + val userImage = extractValueFromDocumentOrEmpty( + document = this, + key = DocumentJsonKeys.PORTRAIT + ) + + return DocumentUi( + documentId = this.id, + documentName = this.toUiName(resourceProvider), + documentIdentifier = this.toDocumentIdentifier(), + documentImage = "", + documentExpirationDateFormatted = documentExpirationDate, + documentHasExpired = docHasExpired, + documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued + ) to UserInfo( + userFirstName = userFirstName, + userBase64Portrait = userImage + ) + } + + else -> { + return DocumentUi( + documentId = this.id, + documentName = this.toUiName(resourceProvider), + documentIdentifier = this.toDocumentIdentifier(), + documentImage = "", + documentExpirationDateFormatted = "", + documentHasExpired = false, + documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Pending + ) to UserInfo( + userFirstName = "", + userBase64Portrait = "" + ) + } + } + } + + private suspend fun tryIssuingDeferredDocumentSuspend( + deferredDocumentId: DocumentId, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): DashboardInteractorRetryIssuingDeferredDocumentPartialState { + return withContext(dispatcher) { + walletCoreDocumentsController.issueDeferredDocument(deferredDocumentId) + .map { result -> + when (result) { + is IssueDeferredDocumentPartialState.Failed -> { + DashboardInteractorRetryIssuingDeferredDocumentPartialState.Failure( + documentId = result.documentId, + errorMessage = result.errorMessage + ) + } + + is IssueDeferredDocumentPartialState.Issued -> { + DashboardInteractorRetryIssuingDeferredDocumentPartialState.Success( + deferredDocumentData = result.deferredDocumentData + ) + } + + is IssueDeferredDocumentPartialState.NotReady -> { + DashboardInteractorRetryIssuingDeferredDocumentPartialState.NotReady( + deferredDocumentData = result.deferredDocumentData + ) + } + + is IssueDeferredDocumentPartialState.Expired -> { + DashboardInteractorRetryIssuingDeferredDocumentPartialState.Expired( + documentId = result.documentId + ) + } + } + }.firstOrNull() + ?: DashboardInteractorRetryIssuingDeferredDocumentPartialState.Failure( + documentId = deferredDocumentId, + errorMessage = genericErrorMsg + ) + } + } } \ No newline at end of file diff --git a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/model/UserInfo.kt b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/model/UserInfo.kt new file mode 100644 index 00000000..8b5bfa43 --- /dev/null +++ b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/model/UserInfo.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the EUPL, Version 1.2 or - as soon they will be approved by the European + * Commission - subsequent versions of the EUPL (the "Licence"); You may not use this work + * except in compliance with the Licence. + * + * You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the Licence for the specific language + * governing permissions and limitations under the Licence. + */ + +package eu.europa.ec.dashboardfeature.model + +data class UserInfo( + val userFirstName: String, + val userBase64Portrait: String +) \ No newline at end of file diff --git a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardScreen.kt b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardScreen.kt index 0af434d2..cb224846 100644 --- a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardScreen.kt +++ b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardScreen.kt @@ -20,6 +20,7 @@ import android.Manifest import android.content.Context import android.os.Build import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -63,11 +64,13 @@ import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.corelogic.model.DocumentIdentifier import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.theme.values.allCorneredShapeSmall import eu.europa.ec.resourceslogic.theme.values.backgroundDefault import eu.europa.ec.resourceslogic.theme.values.bottomCorneredShapeSmall +import eu.europa.ec.resourceslogic.theme.values.textDisabledDark import eu.europa.ec.resourceslogic.theme.values.textPrimaryDark import eu.europa.ec.resourceslogic.theme.values.textSecondaryDark import eu.europa.ec.resourceslogic.theme.values.warning @@ -80,9 +83,12 @@ import eu.europa.ec.uilogic.component.content.GradientEdge import eu.europa.ec.uilogic.component.content.ScreenNavigateAction import eu.europa.ec.uilogic.component.preview.PreviewTheme import eu.europa.ec.uilogic.component.preview.ThemeModePreviews +import eu.europa.ec.uilogic.component.utils.ALPHA_DISABLED +import eu.europa.ec.uilogic.component.utils.ALPHA_ENABLED import eu.europa.ec.uilogic.component.utils.HSpacer import eu.europa.ec.uilogic.component.utils.LifecycleEffect import eu.europa.ec.uilogic.component.utils.SIZE_LARGE +import eu.europa.ec.uilogic.component.utils.SIZE_MEDIUM import eu.europa.ec.uilogic.component.utils.SIZE_SMALL import eu.europa.ec.uilogic.component.utils.SPACING_EXTRA_LARGE import eu.europa.ec.uilogic.component.utils.SPACING_EXTRA_SMALL @@ -90,9 +96,10 @@ import eu.europa.ec.uilogic.component.utils.SPACING_LARGE import eu.europa.ec.uilogic.component.utils.SPACING_MEDIUM import eu.europa.ec.uilogic.component.utils.SPACING_SMALL import eu.europa.ec.uilogic.component.utils.VSpacer +import eu.europa.ec.uilogic.component.wrap.BottomSheetWithOptionsList import eu.europa.ec.uilogic.component.wrap.DialogBottomSheet import eu.europa.ec.uilogic.component.wrap.FabData -import eu.europa.ec.uilogic.component.wrap.SheetContent +import eu.europa.ec.uilogic.component.wrap.GenericBaseSheetContent import eu.europa.ec.uilogic.component.wrap.WrapCard import eu.europa.ec.uilogic.component.wrap.WrapIcon import eu.europa.ec.uilogic.component.wrap.WrapIconButton @@ -100,6 +107,7 @@ import eu.europa.ec.uilogic.component.wrap.WrapModalBottomSheet import eu.europa.ec.uilogic.component.wrap.WrapPrimaryExtendedFab import eu.europa.ec.uilogic.component.wrap.WrapSecondaryExtendedFab import eu.europa.ec.uilogic.extension.IconWarningIndicator +import eu.europa.ec.uilogic.extension.dashedBorder import eu.europa.ec.uilogic.extension.finish import eu.europa.ec.uilogic.extension.getPendingDeepLink import eu.europa.ec.uilogic.extension.openAppSettings @@ -179,6 +187,15 @@ fun DashboardScreen( ) ) } + + LifecycleEffect( + lifecycleOwner = LocalLifecycleOwner.current, + lifecycleEvent = Lifecycle.Event.ON_PAUSE + ) { + viewModel.setEvent( + Event.OnPause + ) + } } private fun handleNavigationEffect( @@ -188,7 +205,14 @@ private fun handleNavigationEffect( ) { when (navigationEffect) { is Effect.Navigation.Pop -> context.finish() - is Effect.Navigation.SwitchScreen -> navController.navigate(navigationEffect.screenRoute) + is Effect.Navigation.SwitchScreen -> { + navController.navigate(navigationEffect.screenRoute) { + popUpTo(navigationEffect.popUpToScreenRoute) { + inclusive = navigationEffect.inclusive + } + } + } + is Effect.Navigation.OpenDeepLinkAction -> { handleDeepLinkAction( navController, @@ -221,6 +245,7 @@ private fun Content( message = stringResource(id = R.string.dashboard_title), userFirstName = state.userFirstName, userBase64Image = state.userBase64Image, + allowUserInteraction = state.allowUserInteraction, onEventSend = onEventSend, paddingValues = paddingValues ) @@ -238,10 +263,12 @@ private fun Content( ) } - FabContent( - paddingValues = paddingValues, - onEventSend = onEventSend - ) + if (state.allowUserInteraction) { + FabContent( + paddingValues = paddingValues, + onEventSend = onEventSend + ) + } } if (state.bleAvailability == BleAvailability.NO_PERMISSION) { @@ -266,6 +293,10 @@ private fun Content( is Effect.ShowBottomSheet -> { onEventSend(Event.BottomSheet.UpdateBottomSheetState(isOpen = true)) } + + is Effect.DocumentsFetched -> { + onEventSend(Event.TryIssuingDeferredDocuments(effect.deferredDocs)) + } } }.collect() } @@ -278,8 +309,8 @@ private fun DashboardSheetContent( onEventSent: (event: Event) -> Unit ) { when (sheetContent) { - is DashboardBottomSheetContent.OPTIONS -> { - SheetContent( + is DashboardBottomSheetContent.Options -> { + GenericBaseSheetContent( titleContent = { Row( modifier = Modifier.fillMaxWidth(), @@ -366,7 +397,7 @@ private fun DashboardSheetContent( ) } - is DashboardBottomSheetContent.BLUETOOTH -> { + is DashboardBottomSheetContent.Bluetooth -> { DialogBottomSheet( title = stringResource(id = R.string.dashboard_bottom_sheet_bluetooth_title), message = stringResource(id = R.string.dashboard_bottom_sheet_bluetooth_subtitle), @@ -382,6 +413,47 @@ private fun DashboardSheetContent( onNegativeClick = { onEventSent(Event.BottomSheet.Bluetooth.SecondaryButtonPressed) } ) } + + is DashboardBottomSheetContent.DeferredDocumentPressed -> { + DialogBottomSheet( + title = stringResource( + id = R.string.dashboard_bottom_sheet_deferred_document_pressed_title, + sheetContent.documentUi.documentName + ), + message = stringResource( + id = R.string.dashboard_bottom_sheet_deferred_document_pressed_subtitle, + sheetContent.documentUi.documentName + ), + positiveButtonText = stringResource(id = R.string.dashboard_bottom_sheet_deferred_document_pressed_primary_button_text), + negativeButtonText = stringResource(id = R.string.dashboard_bottom_sheet_deferred_document_pressed_secondary_button_text), + onPositiveClick = { + onEventSent( + Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.PrimaryButtonPressed( + documentUi = sheetContent.documentUi + ) + ) + }, + onNegativeClick = { + onEventSent( + Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.SecondaryButtonPressed( + documentUi = sheetContent.documentUi + ) + ) + } + ) + } + + is DashboardBottomSheetContent.DeferredDocumentsReady -> { + BottomSheetWithOptionsList( + title = stringResource( + id = R.string.dashboard_bottom_sheet_deferred_documents_ready_title + ), + message = stringResource( + id = R.string.dashboard_bottom_sheet_deferred_documents_ready_subtitle + ), + options = sheetContent.options, + ) + } } } @@ -441,6 +513,7 @@ private fun Title( message: String, userFirstName: String, userBase64Image: String, + allowUserInteraction: Boolean, onEventSend: (Event) -> Unit, paddingValues: PaddingValues ) { @@ -493,6 +566,7 @@ private fun Title( WrapIconButton( iconData = AppIcons.VerticalMore, customTint = MaterialTheme.colorScheme.primary, + enabled = allowUserInteraction, onClick = { onEventSend(Event.OptionsPressed) } @@ -526,7 +600,7 @@ private fun DocumentsList( } items( - documents.size, + count = documents.size, key = { documents[it].documentId } ) { index -> CardListItem( @@ -547,92 +621,243 @@ private fun CardListItem( dataItem: DocumentUi, onEventSend: (Event) -> Unit ) { + val dottedLinesColor = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.textSecondaryDark + } else { + MaterialTheme.colorScheme.textDisabledDark + } + + val borderModifier = when (dataItem.documentIssuanceState) { + DocumentUiIssuanceState.Issued -> Modifier + DocumentUiIssuanceState.Pending, DocumentUiIssuanceState.Failed -> Modifier + .dashedBorder( + color = dottedLinesColor, + shape = RoundedCornerShape(SIZE_MEDIUM.dp), + strokeWidth = 2.dp, + gapLength = SIZE_SMALL.dp + ) + } + WrapCard( modifier = Modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .then(borderModifier), onClick = { - onEventSend( - Event.NavigateToDocument( - documentId = dataItem.documentId, - documentType = dataItem.documentIdentifier.docType, - ) - ) + when (dataItem.documentIssuanceState) { + DocumentUiIssuanceState.Issued -> { + onEventSend( + Event.NavigateToDocument( + documentId = dataItem.documentId + ) + ) + } + + DocumentUiIssuanceState.Pending, DocumentUiIssuanceState.Failed -> { + onEventSend( + Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.DocumentSelected( + documentUi = dataItem + ) + ) + } + } }, throttleClicks = true, ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(SPACING_MEDIUM.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Box { - WrapIcon( - iconData = AppIcons.Id, - customTint = MaterialTheme.colorScheme.primary - ) + DocumentContent(dataItem) + } +} + +@Composable +private fun DocumentContent(dataItem: DocumentUi) { + val documentState = dataItem.documentIssuanceState + val iconData = AppIcons.Id + val iconTint = when (documentState) { + DocumentUiIssuanceState.Issued -> MaterialTheme.colorScheme.primary + DocumentUiIssuanceState.Pending, DocumentUiIssuanceState.Failed -> MaterialTheme.colorScheme.textDisabledDark + } + val iconAlpha = when (documentState) { + DocumentUiIssuanceState.Issued -> ALPHA_ENABLED + DocumentUiIssuanceState.Pending, DocumentUiIssuanceState.Failed -> ALPHA_DISABLED + } + + val warningIconData = when (documentState) { + DocumentUiIssuanceState.Issued -> AppIcons.Warning + DocumentUiIssuanceState.Pending -> AppIcons.ClockTimer + DocumentUiIssuanceState.Failed -> AppIcons.ErrorFilled + } + val warningIconTint = when (documentState) { + DocumentUiIssuanceState.Issued, DocumentUiIssuanceState.Pending -> { + MaterialTheme.colorScheme.warning + } + + DocumentUiIssuanceState.Failed -> { + MaterialTheme.colorScheme.error + } + + } + val documentNameColor = when (documentState) { + DocumentUiIssuanceState.Issued -> { + MaterialTheme.colorScheme.textPrimaryDark + } + + DocumentUiIssuanceState.Pending, DocumentUiIssuanceState.Failed -> { + MaterialTheme.colorScheme.textDisabledDark + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(SPACING_MEDIUM.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box { + WrapIcon( + iconData = iconData, + customTint = iconTint, + contentAlpha = iconAlpha + ) + if (documentState == DocumentUiIssuanceState.Issued) { if (dataItem.documentHasExpired) { IconWarningIndicator( backgroundColor = MaterialTheme.colorScheme.backgroundDefault ) } + } else { + IconWarningIndicator( + iconData = warningIconData, + customTint = warningIconTint, + backgroundColor = MaterialTheme.colorScheme.backgroundDefault + ) } - Box( - modifier = Modifier - .wrapContentWidth() - .height(28.dp), - contentAlignment = Alignment.Center - ) { - ScalableText( - text = dataItem.documentName, - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.textPrimaryDark - ) + } + Box( + modifier = Modifier + .wrapContentWidth() + .height(28.dp), + contentAlignment = Alignment.Center + ) { + ScalableText( + text = dataItem.documentName, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = documentNameColor + ) + ) + } + VSpacer.Small() + ExpirationInfo(dataItem) + } +} + +@Composable +private fun IssuedDocument(dataItem: DocumentUi) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(SPACING_MEDIUM.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box { + WrapIcon( + iconData = AppIcons.Id, + customTint = MaterialTheme.colorScheme.primary + ) + if (dataItem.documentHasExpired) { + IconWarningIndicator( + backgroundColor = MaterialTheme.colorScheme.backgroundDefault ) } - VSpacer.Small() - ExpiryDate( - expirationDate = dataItem.documentExpirationDateFormatted, - hasExpired = dataItem.documentHasExpired + } + Box( + modifier = Modifier + .wrapContentWidth() + .height(28.dp), + contentAlignment = Alignment.Center + ) { + ScalableText( + text = dataItem.documentName, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.textPrimaryDark + ) ) } + VSpacer.Small() + ExpirationInfo(dataItem) } } @Composable -private fun ExpiryDate( - expirationDate: String, - hasExpired: Boolean +private fun ExpirationInfo( + document: DocumentUi, ) { val textStyle = MaterialTheme.typography.bodySmall .copy(color = MaterialTheme.colorScheme.textSecondaryDark) + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - if (hasExpired) { - val annotatedText = buildAnnotatedString { - withStyle( - style = SpanStyle( - fontStyle = MaterialTheme.typography.bodySmall.fontStyle, - color = MaterialTheme.colorScheme.warning - ) - ) { - append(stringResource(id = R.string.dashboard_document_has_expired_one)) + with(document) { + when (documentIssuanceState) { + DocumentUiIssuanceState.Issued -> { + if (documentHasExpired) { + val annotatedText = buildAnnotatedString { + withStyle( + style = SpanStyle( + fontStyle = textStyle.fontStyle, + color = MaterialTheme.colorScheme.warning + ) + ) { + append(stringResource(id = R.string.dashboard_document_has_expired_one)) + } + + append(stringResource(id = R.string.dashboard_document_has_expired_two)) + } + Text(text = annotatedText, style = textStyle) + } else { + Text( + text = stringResource(id = R.string.dashboard_document_has_not_expired), + style = textStyle + ) + } + + //Expiration Date + Text(text = documentExpirationDateFormatted, style = textStyle) + } + + DocumentUiIssuanceState.Pending -> { + val annotatedText = buildAnnotatedString { + withStyle( + style = SpanStyle( + fontStyle = textStyle.fontStyle, + color = MaterialTheme.colorScheme.warning + ) + ) { + append(stringResource(id = R.string.dashboard_document_deferred_pending)) + } + } + Text(text = annotatedText, style = textStyle) + } + + DocumentUiIssuanceState.Failed -> { + val annotatedText = buildAnnotatedString { + withStyle( + style = SpanStyle( + fontStyle = textStyle.fontStyle, + color = MaterialTheme.colorScheme.error + ) + ) { + append(stringResource(id = R.string.dashboard_document_deferred_failed)) + } + } + Text(text = annotatedText, style = textStyle) } - append(stringResource(id = R.string.dashboard_document_has_expired_two)) } - Text(text = annotatedText, style = textStyle) - } else { - Text( - text = stringResource(id = R.string.dashboard_document_has_not_expired), - style = textStyle - ) } - Text(text = expirationDate, style = textStyle) } } @@ -650,6 +875,7 @@ private fun DashboardScreenPreview() { documentHasExpired = false, documentImage = "image1", documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued ), DocumentUi( documentId = "1", @@ -659,6 +885,7 @@ private fun DashboardScreenPreview() { documentHasExpired = false, documentImage = "image2", documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Pending ), DocumentUi( documentId = "2", @@ -671,6 +898,7 @@ private fun DashboardScreenPreview() { documentHasExpired = true, documentImage = "image3", documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Pending ) ) Content( @@ -731,7 +959,7 @@ private fun RequiredPermissionsAsk( private fun SheetContentPreview() { PreviewTheme { DashboardSheetContent( - sheetContent = DashboardBottomSheetContent.OPTIONS, + sheetContent = DashboardBottomSheetContent.Options, state = State(), onEventSent = {} ) diff --git a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardViewModel.kt b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardViewModel.kt index 7a7162cc..428f0511 100644 --- a/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardViewModel.kt +++ b/dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardViewModel.kt @@ -25,14 +25,21 @@ import eu.europa.ec.commonfeature.config.QrScanFlow import eu.europa.ec.commonfeature.config.QrScanUiConfig import eu.europa.ec.commonfeature.config.RequestUriConfig import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.model.PinFlow import eu.europa.ec.corelogic.di.getOrCreatePresentationScope +import eu.europa.ec.corelogic.model.DeferredDocumentData import eu.europa.ec.corelogic.model.DocType import eu.europa.ec.dashboardfeature.interactor.DashboardInteractor -import eu.europa.ec.dashboardfeature.interactor.DashboardInteractorPartialState +import eu.europa.ec.dashboardfeature.interactor.DashboardInteractorDeleteDocumentPartialState +import eu.europa.ec.dashboardfeature.interactor.DashboardInteractorGetDocumentsPartialState +import eu.europa.ec.dashboardfeature.interactor.DashboardInteractorRetryIssuingDeferredDocumentsPartialState +import eu.europa.ec.eudi.wallet.document.Document +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider import eu.europa.ec.uilogic.component.content.ContentErrorConfig +import eu.europa.ec.uilogic.component.wrap.OptionListItemUi import eu.europa.ec.uilogic.config.ConfigNavigation import eu.europa.ec.uilogic.config.NavigationType import eu.europa.ec.uilogic.mvi.MviViewModel @@ -43,11 +50,14 @@ import eu.europa.ec.uilogic.navigation.CommonScreens import eu.europa.ec.uilogic.navigation.DashboardScreens import eu.europa.ec.uilogic.navigation.IssuanceScreens import eu.europa.ec.uilogic.navigation.ProximityScreens +import eu.europa.ec.uilogic.navigation.StartupScreens import eu.europa.ec.uilogic.navigation.helper.DeepLinkType import eu.europa.ec.uilogic.navigation.helper.generateComposableArguments import eu.europa.ec.uilogic.navigation.helper.generateComposableNavigationLink import eu.europa.ec.uilogic.navigation.helper.hasDeepLink import eu.europa.ec.uilogic.serializer.UiSerializer +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @@ -59,7 +69,7 @@ data class State( val isLoading: Boolean = true, val error: ContentErrorConfig? = null, val isBottomSheetOpen: Boolean = false, - val sheetContent: DashboardBottomSheetContent = DashboardBottomSheetContent.OPTIONS, + val sheetContent: DashboardBottomSheetContent = DashboardBottomSheetContent.Options, val bleAvailability: BleAvailability = BleAvailability.UNKNOWN, val isBleCentralClientModeEnabled: Boolean = false, @@ -67,16 +77,19 @@ data class State( val userFirstName: String = "", val userBase64Image: String = "", val documents: List = emptyList(), + val deferredFailedDocIds: List = emptyList(), + val allowUserInteraction: Boolean = false, - val appVersion: String = "" + val appVersion: String = "", ) : ViewState sealed class Event : ViewEvent { data class Init(val deepLinkUri: Uri?) : Event() + data object OnPause : Event() + data class TryIssuingDeferredDocuments(val deferredDocs: Map) : Event() data object Pop : Event() data class NavigateToDocument( - val documentId: String, - val documentType: DocType, + val documentId: DocumentId ) : Event() data object OptionsPressed : Event() @@ -99,6 +112,23 @@ sealed class Event : ViewEvent { data class PrimaryButtonPressed(val availability: BleAvailability) : Bluetooth() data object SecondaryButtonPressed : Bluetooth() } + + sealed class DeferredDocument : BottomSheet() { + sealed class DeferredNotReadyYet(open val documentUi: DocumentUi) : DeferredDocument() { + data class DocumentSelected(override val documentUi: DocumentUi) : + DeferredNotReadyYet(documentUi) + + data class PrimaryButtonPressed(override val documentUi: DocumentUi) : + DeferredNotReadyYet(documentUi) + + data class SecondaryButtonPressed(override val documentUi: DocumentUi) : + DeferredNotReadyYet(documentUi) + } + + data class OptionListItemForSuccessfullyIssuingDeferredDocumentSelected( + val documentId: DocumentId + ) : DeferredDocument() + } } data object OnShowPermissionsRational : Event() @@ -108,7 +138,12 @@ sealed class Event : ViewEvent { sealed class Effect : ViewSideEffect { sealed class Navigation : Effect() { data object Pop : Navigation() - data class SwitchScreen(val screenRoute: String) : Navigation() + data class SwitchScreen( + val screenRoute: String, + val popUpToScreenRoute: String = DashboardScreens.Dashboard.screenRoute, + val inclusive: Boolean = false, + ) : Navigation() + data class OpenDeepLinkAction(val deepLinkUri: Uri, val arguments: String?) : Navigation() @@ -116,14 +151,21 @@ sealed class Effect : ViewSideEffect { data object OnSystemSettings : Navigation() } + data class DocumentsFetched(val deferredDocs: Map) : Effect() + data object ShowBottomSheet : Effect() data object CloseBottomSheet : Effect() } sealed class DashboardBottomSheetContent { - data object OPTIONS : DashboardBottomSheetContent() - - data class BLUETOOTH(val availability: BleAvailability) : DashboardBottomSheetContent() + data object Options : DashboardBottomSheetContent() + + data class Bluetooth(val availability: BleAvailability) : DashboardBottomSheetContent() + data class DeferredDocumentPressed(val documentUi: DocumentUi) : DashboardBottomSheetContent() + data class DeferredDocumentsReady( + val successfullyIssuedDeferredDocuments: List, + val options: List, + ) : DashboardBottomSheetContent() } @KoinViewModel @@ -133,6 +175,8 @@ class DashboardViewModel( private val resourceProvider: ResourceProvider, ) : MviViewModel() { + private var retryDeferredDocsJob: Job? = null + override fun setInitialState(): State = State( isBleCentralClientModeEnabled = dashboardInteractor.isBleCentralClientModeEnabled(), appVersion = dashboardInteractor.getAppVersion() @@ -141,30 +185,29 @@ class DashboardViewModel( override fun handleEvents(event: Event) { when (event) { is Event.Init -> { - getDocuments(event, event.deepLinkUri) + getDocuments( + event = event, + deepLinkUri = event.deepLinkUri, + deferredFailedDocIds = viewState.value.deferredFailedDocIds + ) + } + + is Event.OnPause -> { + retryDeferredDocsJob?.cancel() + } + + is Event.TryIssuingDeferredDocuments -> { + tryIssuingDeferredDocuments(event, event.deferredDocs) } is Event.Pop -> setEffect { Effect.Navigation.Pop } is Event.NavigateToDocument -> { - setEffect { - Effect.Navigation.SwitchScreen( - generateComposableNavigationLink( - screen = IssuanceScreens.DocumentDetails, - arguments = generateComposableArguments( - mapOf( - "detailsType" to IssuanceFlowUiConfig.EXTRA_DOCUMENT, - "documentId" to event.documentId, - "documentType" to event.documentType, - ) - ) - ) - ) - } + goToDocumentDetails(docId = event.documentId) } is Event.OptionsPressed -> { - showBottomSheet(sheetContent = DashboardBottomSheetContent.OPTIONS) + showBottomSheet(sheetContent = DashboardBottomSheetContent.Options) } is Event.StartProximityFlow -> { @@ -219,12 +262,36 @@ class DashboardViewModel( is Event.OnShowPermissionsRational -> { setState { copy(bleAvailability = BleAvailability.UNKNOWN) } - showBottomSheet(sheetContent = DashboardBottomSheetContent.BLUETOOTH(BleAvailability.NO_PERMISSION)) + showBottomSheet(sheetContent = DashboardBottomSheetContent.Bluetooth(BleAvailability.NO_PERMISSION)) } is Event.OnPermissionStateChanged -> { setState { copy(bleAvailability = event.availability) } } + + is Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.DocumentSelected -> { + showBottomSheet( + sheetContent = DashboardBottomSheetContent.DeferredDocumentPressed( + documentUi = event.documentUi + ) + ) + } + + is Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.PrimaryButtonPressed -> { + hideBottomSheet() + deleteDocument(event = event, documentId = event.documentUi.documentId) + } + + is Event.BottomSheet.DeferredDocument.DeferredNotReadyYet.SecondaryButtonPressed -> { + hideBottomSheet() + } + + is Event.BottomSheet.DeferredDocument.OptionListItemForSuccessfullyIssuingDeferredDocumentSelected -> { + hideBottomSheet() + goToDocumentDetails( + docId = event.documentId + ) + } } } @@ -248,11 +315,31 @@ class DashboardViewModel( setState { copy(bleAvailability = BleAvailability.NO_PERMISSION) } } else { setState { copy(bleAvailability = BleAvailability.DISABLED) } - showBottomSheet(sheetContent = DashboardBottomSheetContent.BLUETOOTH(BleAvailability.DISABLED)) + showBottomSheet(sheetContent = DashboardBottomSheetContent.Bluetooth(BleAvailability.DISABLED)) } } - private fun getDocuments(event: Event, deepLinkUri: Uri?) { + private fun goToDocumentDetails(docId: DocumentId) { + setEffect { + Effect.Navigation.SwitchScreen( + screenRoute = generateComposableNavigationLink( + screen = IssuanceScreens.DocumentDetails, + arguments = generateComposableArguments( + mapOf( + "detailsType" to IssuanceFlowUiConfig.EXTRA_DOCUMENT, + "documentId" to docId + ) + ) + ) + ) + } + } + + private fun getDocuments( + event: Event, + deepLinkUri: Uri?, + deferredFailedDocIds: List, + ) { setState { copy( isLoading = documents.isEmpty(), @@ -262,7 +349,7 @@ class DashboardViewModel( viewModelScope.launch { dashboardInteractor.getDocuments().collect { response -> when (response) { - is DashboardInteractorPartialState.Failure -> { + is DashboardInteractorGetDocumentsPartialState.Failure -> { setState { copy( isLoading = false, @@ -278,16 +365,43 @@ class DashboardViewModel( } } - is DashboardInteractorPartialState.Success -> { + is DashboardInteractorGetDocumentsPartialState.Success -> { + val shouldAllowUserInteraction = + response.mainPid?.state == Document.State.ISSUED + + val documents = response.documentsUi + .map { documentUi -> + if (documentUi.documentId in deferredFailedDocIds) { + documentUi.copy( + documentIssuanceState = DocumentUiIssuanceState.Failed + ) + } else { + documentUi + } + } + + val deferredDocs: MutableMap = mutableMapOf() + response.documentsUi.filter { documentUi -> + documentUi.documentIssuanceState == DocumentUiIssuanceState.Pending + }.forEach { documentUi -> + deferredDocs[documentUi.documentId] = + documentUi.documentIdentifier.docType + } + setState { copy( isLoading = false, error = null, - documents = response.documents, + documents = documents, + deferredFailedDocIds = deferredFailedDocIds, + allowUserInteraction = shouldAllowUserInteraction, userFirstName = response.userFirstName, userBase64Image = response.userBase64Portrait ) } + + setEffect { Effect.DocumentsFetched(deferredDocs) } + handleDeepLink(deepLinkUri) } } @@ -295,6 +409,137 @@ class DashboardViewModel( } } + private fun tryIssuingDeferredDocuments(event: Event, deferredDocs: Map) { + setState { + copy( + isLoading = false, + error = null + ) + } + + retryDeferredDocsJob?.cancel() + retryDeferredDocsJob = viewModelScope.launch { + if (deferredDocs.isEmpty()) { + return@launch + } + + delay(5000L) + + dashboardInteractor.tryIssuingDeferredDocumentsFlow(deferredDocs).collect { response -> + when (response) { + is DashboardInteractorRetryIssuingDeferredDocumentsPartialState.Failure -> { + setState { + copy( + isLoading = false, + error = ContentErrorConfig( + onRetry = { setEvent(event) }, + errorSubTitle = response.errorMessage, + onCancel = { + setState { copy(error = null) } + } + ) + ) + } + } + + is DashboardInteractorRetryIssuingDeferredDocumentsPartialState.Result -> { + val successDocs = response.successfullyIssuedDeferredDocuments + if (successDocs.isNotEmpty()) { + showBottomSheet( + sheetContent = DashboardBottomSheetContent.DeferredDocumentsReady( + successfullyIssuedDeferredDocuments = successDocs, + options = getBottomSheetOptions( + deferredDocumentsData = successDocs + ) + ) + ) + } + + getDocuments( + event = event, + deepLinkUri = null, + deferredFailedDocIds = response.failedIssuedDeferredDocuments + ) + } + } + } + } + } + + private fun getBottomSheetOptions(deferredDocumentsData: List): List { + return deferredDocumentsData.map { + OptionListItemUi( + text = it.docName, + onClick = { + setEvent( + Event.BottomSheet.DeferredDocument.OptionListItemForSuccessfullyIssuingDeferredDocumentSelected( + documentId = it.documentId + ) + ) + } + ) + } + } + + private fun deleteDocument(event: Event, documentId: DocumentId) { + setState { + copy( + isLoading = true, + error = null + ) + } + + viewModelScope.launch { + dashboardInteractor.deleteDocument( + documentId = documentId + ).collect { response -> + when (response) { + is DashboardInteractorDeleteDocumentPartialState.AllDocumentsDeleted -> { + setState { + copy( + isLoading = false, + error = null + ) + } + + setEffect { + Effect.Navigation.SwitchScreen( + screenRoute = StartupScreens.Splash.screenRoute, + popUpToScreenRoute = DashboardScreens.Dashboard.screenRoute, + inclusive = true + ) + } + } + + is DashboardInteractorDeleteDocumentPartialState.SingleDocumentDeleted -> { + getDocuments( + event = event, + deepLinkUri = null, + deferredFailedDocIds = viewState.value.deferredFailedDocIds + ) + } + + is DashboardInteractorDeleteDocumentPartialState.Failure -> { + setState { + copy( + isLoading = false, + error = ContentErrorConfig( + onRetry = { setEvent(event) }, + errorSubTitle = response.errorMessage, + onCancel = { + setState { + copy(error = null) + } + } + ) + ) + } + } + } + } + } + } + private fun handleDeepLink(deepLinkUri: Uri?) { deepLinkUri?.let { uri -> hasDeepLink(uri)?.let { @@ -419,4 +664,9 @@ class DashboardViewModel( Effect.CloseBottomSheet } } + + override fun onCleared() { + super.onCleared() + retryDeferredDocsJob?.cancel() + } } \ No newline at end of file diff --git a/dashboard-feature/src/test/java/eu/europa/ec/dashboardfeature/interactor/TestDashboardInteractor.kt b/dashboard-feature/src/test/java/eu/europa/ec/dashboardfeature/interactor/TestDashboardInteractor.kt index 05dd68a3..d63fdf34 100644 --- a/dashboard-feature/src/test/java/eu/europa/ec/dashboardfeature/interactor/TestDashboardInteractor.kt +++ b/dashboard-feature/src/test/java/eu/europa/ec/dashboardfeature/interactor/TestDashboardInteractor.kt @@ -31,13 +31,16 @@ import eu.europa.ec.commonfeature.util.TestsData.mockedUserFirstName import eu.europa.ec.corelogic.config.WalletCoreConfig import eu.europa.ec.corelogic.controller.WalletCoreDocumentsController import eu.europa.ec.eudi.wallet.EudiWalletConfig +import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider import eu.europa.ec.testfeature.MockResourceProviderForStringCalls.mockDocumentTypeUiToUiNameCall import eu.europa.ec.testfeature.mockedExceptionWithMessage import eu.europa.ec.testfeature.mockedExceptionWithNoMessage import eu.europa.ec.testfeature.mockedFullDocuments +import eu.europa.ec.testfeature.mockedFullPid import eu.europa.ec.testfeature.mockedGenericErrorMessage +import eu.europa.ec.testfeature.mockedMainPid import eu.europa.ec.testfeature.mockedMdlWithNoExpirationDate import eu.europa.ec.testfeature.mockedMdlWithNoUserNameAndNoUserImage import eu.europa.ec.testfeature.walletcore.getMockedEudiWalletConfig @@ -46,6 +49,7 @@ import eu.europa.ec.testlogic.base.getMockedContext import eu.europa.ec.testlogic.extension.runFlowTest import eu.europa.ec.testlogic.extension.runTest import eu.europa.ec.testlogic.rule.CoroutineTestRule +import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule @@ -60,7 +64,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBluetoothAdapter -import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(application = TestApplication::class) @@ -192,18 +195,21 @@ class TestDashboardInteractor { // Case 1: // walletCoreDocumentsController.getAllDocuments() returns - // a full PID and a full mDL. + // a full PID and a full mDL, and + // walletCoreDocumentsController.getMainPidDocument() returns a main PID. // Case 1 Expected Result: - // DashboardInteractorPartialState.Success state, with: + // DashboardInteractorGetDocumentsPartialState.Success state, with: // 1. the list of Documents transformed to DocumentUi objects, // 2. an actual user name, and - // 3. an actual (base64 encoded) user image. + // 3. an actual (base64 encoded) user image, and + // 4. the main PID. @Test fun `Given Case 1, When getDocuments is called, Then Case 1 Expected Result is returned`() { coroutineRule.runTest { // Given mockGetStringForDocumentsCall(resourceProvider) + mockGetMainPidDocumentCall(mockedMainPid) whenever(walletCoreDocumentsController.getAllDocuments()) .thenReturn(mockedFullDocuments) @@ -212,10 +218,11 @@ class TestDashboardInteractor { interactor.getDocuments().runFlowTest { // Then assertEquals( - DashboardInteractorPartialState.Success( - documents = mockedFullDocumentsUi, + DashboardInteractorGetDocumentsPartialState.Success( + documentsUi = mockedFullDocumentsUi, userFirstName = mockedUserFirstName, - userBase64Portrait = mockedUserBase64Portrait + userBase64Portrait = mockedUserBase64Portrait, + mainPid = mockedFullPid, ), awaitItem() ) @@ -225,14 +232,16 @@ class TestDashboardInteractor { // Case 2: // walletCoreDocumentsController.getAllDocuments() returns - // an mDL with no user name, and - // no user image. + // an mDL with no user name, + // no user image, and + // walletCoreDocumentsController.getMainPidDocument() returns null. // Case 2 Expected Result: - // DashboardInteractorPartialState.Success state, with: - // 1. the Document transformed to DocumentUi object, - // 2. empty string for the user name, and - // 3. empty string for the user image. + // DashboardInteractorGetDocumentsPartialState.Success state, with: + // 1. the DeferredDocument transformed to DocumentUi object, + // 2. empty string for the user name, + // 3. empty string for the user image, and + // 4. null for the main PID. @Test fun `Given Case 2, When getDocuments is called, Then Case 2 Expected Result is returned`() { coroutineRule.runTest { @@ -246,10 +255,11 @@ class TestDashboardInteractor { interactor.getDocuments().runFlowTest { // Then assertEquals( - DashboardInteractorPartialState.Success( - documents = listOf(mockedMdlUiWithNoUserNameAndNoUserImage), + DashboardInteractorGetDocumentsPartialState.Success( + documentsUi = listOf(mockedMdlUiWithNoUserNameAndNoUserImage), userFirstName = mockedNoUserFistNameFound, - userBase64Portrait = mockedNoUserBase64PortraitFound + userBase64Portrait = mockedNoUserBase64PortraitFound, + mainPid = null, ), awaitItem() ) @@ -262,10 +272,11 @@ class TestDashboardInteractor { // an mDL with no expiration date. // Case 3 Expected Result: - // DashboardInteractorPartialState.Success state, with: - // 1. the Document transformed to DocumentUi object, + // DashboardInteractorGetDocumentsPartialState.Success state, with: + // 1. the DeferredDocument transformed to DocumentUi object, // 2. an actual user name, and - // 3. an actual (base64 encoded) user image. + // 3. an actual (base64 encoded) user image, and + // 4. null for the main PID. @Test fun `Given Case 3, When getDocuments is called, Then Case 3 Expected Result is returned`() { coroutineRule.runTest { @@ -279,10 +290,11 @@ class TestDashboardInteractor { interactor.getDocuments().runFlowTest { // Then assertEquals( - DashboardInteractorPartialState.Success( - documents = listOf(mockedMdlUiWithNoExpirationDate), + DashboardInteractorGetDocumentsPartialState.Success( + documentsUi = listOf(mockedMdlUiWithNoExpirationDate), userFirstName = mockedUserFirstName, - userBase64Portrait = mockedUserBase64Portrait + userBase64Portrait = mockedUserBase64Portrait, + mainPid = null, ), awaitItem() ) @@ -303,7 +315,7 @@ class TestDashboardInteractor { interactor.getDocuments().runFlowTest { // Then assertEquals( - DashboardInteractorPartialState.Failure( + DashboardInteractorGetDocumentsPartialState.Failure( error = mockedExceptionWithMessage.localizedMessage!! ), awaitItem() @@ -325,7 +337,7 @@ class TestDashboardInteractor { interactor.getDocuments().runFlowTest { // Then assertEquals( - DashboardInteractorPartialState.Failure( + DashboardInteractorGetDocumentsPartialState.Failure( error = mockedGenericErrorMessage ), awaitItem() @@ -369,5 +381,10 @@ class TestDashboardInteractor { whenever(resourceProvider.getString(R.string.dashboard_document_no_expiration_found)) .thenReturn(mockedNoExpirationDateFound) } + + private fun mockGetMainPidDocumentCall(mainPid: IssuedDocument?) { + whenever(walletCoreDocumentsController.getMainPidDocument()) + .thenReturn(mainPid) + } //endregion } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b9f7a23..f6c2296e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,7 @@ gson = "2.10.1" logcat = "0.1" googlePhoneNumber = "8.13.40" zxing = "3.5.2" -eudiWalletCore = "0.10.2-SNAPSHOT" +eudiWalletCore = "0.11.0-SNAPSHOT" cborTree = "0.01.02" cameraCore = "1.3.4" owaspDependencyCheck = "10.0.3" diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/di/FeatureIssuanceModule.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/di/FeatureIssuanceModule.kt index 2b7b1987..bc67223f 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/di/FeatureIssuanceModule.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/di/FeatureIssuanceModule.kt @@ -41,12 +41,14 @@ class FeatureIssuanceModule fun provideAddDocumentInteractor( walletCoreDocumentsController: WalletCoreDocumentsController, resourceProvider: ResourceProvider, - deviceAuthenticationInteractor: DeviceAuthenticationInteractor + deviceAuthenticationInteractor: DeviceAuthenticationInteractor, + uiSerializer: UiSerializer, ): AddDocumentInteractor = AddDocumentInteractorImpl( walletCoreDocumentsController, deviceAuthenticationInteractor, - resourceProvider + resourceProvider, + uiSerializer, ) @Factory diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/SuccessInteractor.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/SuccessInteractor.kt index 28e09e60..a9cafbd4 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/SuccessInteractor.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/SuccessInteractor.kt @@ -20,6 +20,7 @@ import eu.europa.ec.businesslogic.extension.safeAsync import eu.europa.ec.commonfeature.model.toUiName import eu.europa.ec.commonfeature.util.extractFullNameFromDocumentOrEmpty import eu.europa.ec.corelogic.controller.WalletCoreDocumentsController +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.resourceslogic.provider.ResourceProvider import kotlinx.coroutines.flow.Flow @@ -36,7 +37,7 @@ sealed class SuccessFetchDocumentByIdPartialState { } interface SuccessInteractor { - fun fetchDocumentById(id: String): Flow + fun fetchDocumentById(documentId: DocumentId): Flow } class SuccessInteractorImpl( @@ -47,20 +48,22 @@ class SuccessInteractorImpl( private val genericErrorMsg get() = resourceProvider.genericErrorMessage() - override fun fetchDocumentById(id: String): Flow = flow { - val document = walletCoreDocumentsController.getDocumentById(id = id) - document?.let { - emit( - SuccessFetchDocumentByIdPartialState.Success( - document = it, - documentName = it.toUiName(resourceProvider), - fullName = extractFullNameFromDocumentOrEmpty(it) + override fun fetchDocumentById(documentId: DocumentId): Flow = + flow { + val document = walletCoreDocumentsController.getDocumentById(documentId = documentId) + as? IssuedDocument + document?.let { issuedDocument -> + emit( + SuccessFetchDocumentByIdPartialState.Success( + document = issuedDocument, + documentName = issuedDocument.toUiName(resourceProvider), + fullName = extractFullNameFromDocumentOrEmpty(issuedDocument) + ) ) + } ?: emit(SuccessFetchDocumentByIdPartialState.Failure(genericErrorMsg)) + }.safeAsync { + SuccessFetchDocumentByIdPartialState.Failure( + error = it.localizedMessage ?: genericErrorMsg ) - } ?: emit(SuccessFetchDocumentByIdPartialState.Failure(genericErrorMsg)) - }.safeAsync { - SuccessFetchDocumentByIdPartialState.Failure( - error = it.localizedMessage ?: genericErrorMsg - ) - } + } } \ No newline at end of file diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/AddDocumentInteractor.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/AddDocumentInteractor.kt index 394f4802..d983f24a 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/AddDocumentInteractor.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/AddDocumentInteractor.kt @@ -22,6 +22,7 @@ import eu.europa.ec.authenticationlogic.controller.authentication.DeviceAuthenti import eu.europa.ec.authenticationlogic.model.BiometricCrypto import eu.europa.ec.businesslogic.extension.safeAsync import eu.europa.ec.commonfeature.config.IssuanceFlowUiConfig +import eu.europa.ec.commonfeature.config.SuccessUIConfig import eu.europa.ec.commonfeature.interactor.DeviceAuthenticationInteractor import eu.europa.ec.commonfeature.model.DocumentOptionItemUi import eu.europa.ec.commonfeature.model.toUiName @@ -31,8 +32,17 @@ import eu.europa.ec.corelogic.controller.IssueDocumentPartialState import eu.europa.ec.corelogic.controller.WalletCoreDocumentsController import eu.europa.ec.corelogic.model.DocType import eu.europa.ec.corelogic.model.DocumentIdentifier +import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider +import eu.europa.ec.resourceslogic.theme.values.ThemeColors import eu.europa.ec.uilogic.component.AppIcons +import eu.europa.ec.uilogic.config.ConfigNavigation +import eu.europa.ec.uilogic.config.NavigationType +import eu.europa.ec.uilogic.navigation.CommonScreens +import eu.europa.ec.uilogic.navigation.DashboardScreens +import eu.europa.ec.uilogic.navigation.helper.generateComposableArguments +import eu.europa.ec.uilogic.navigation.helper.generateComposableNavigationLink +import eu.europa.ec.uilogic.serializer.UiSerializer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -58,12 +68,17 @@ interface AddDocumentInteractor { crypto: BiometricCrypto, resultHandler: DeviceAuthenticationResult ) + + fun buildGenericSuccessRouteForDeferred(flowType: IssuanceFlowUiConfig): String + + fun resumeOpenId4VciWithAuthorization(uri: String) } class AddDocumentInteractorImpl( private val walletCoreDocumentsController: WalletCoreDocumentsController, private val deviceAuthenticationInteractor: DeviceAuthenticationInteractor, private val resourceProvider: ResourceProvider, + private val uiSerializer: UiSerializer, ) : AddDocumentInteractor { private val genericErrorMsg get() = resourceProvider.genericErrorMessage() @@ -154,6 +169,68 @@ class AddDocumentInteractorImpl( } } + override fun buildGenericSuccessRouteForDeferred(flowType: IssuanceFlowUiConfig): String { + val navigation = when (flowType) { + IssuanceFlowUiConfig.NO_DOCUMENT -> ConfigNavigation( + navigationType = NavigationType.PushRoute(route = DashboardScreens.Dashboard.screenRoute), + ) + + IssuanceFlowUiConfig.EXTRA_DOCUMENT -> ConfigNavigation( + navigationType = NavigationType.PopTo( + screen = DashboardScreens.Dashboard + ) + ) + } + val successScreenArguments = getSuccessScreenArgumentsForDeferred(navigation) + return generateComposableNavigationLink( + screen = CommonScreens.Success, + arguments = successScreenArguments + ) + } + + override fun resumeOpenId4VciWithAuthorization(uri: String) { + walletCoreDocumentsController.resumeOpenId4VciWithAuthorization(uri) + } + + private fun getSuccessScreenArgumentsForDeferred( + navigation: ConfigNavigation + ): String { + val (headerConfig, imageConfig, buttonText) = Triple( + first = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.issuance_add_document_deferred_success_title), + color = ThemeColors.warning + ), + second = SuccessUIConfig.ImageConfig( + type = SuccessUIConfig.ImageConfig.Type.DRAWABLE, + drawableRes = AppIcons.ClockTimer.resourceId, + tint = ThemeColors.warning, + contentDescription = resourceProvider.getString(AppIcons.ClockTimer.contentDescriptionId) + ), + third = resourceProvider.getString(R.string.issuance_add_document_deferred_success_primary_button_text) + ) + + return generateComposableArguments( + mapOf( + SuccessUIConfig.serializedKeyName to uiSerializer.toBase64( + SuccessUIConfig( + headerConfig = headerConfig, + content = resourceProvider.getString(R.string.issuance_add_document_deferred_success_subtitle), + imageConfig = imageConfig, + buttonConfig = listOf( + SuccessUIConfig.ButtonConfig( + text = buttonText, + style = SuccessUIConfig.ButtonConfig.Style.PRIMARY, + navigation = navigation + ) + ), + onBackScreenToNavigate = navigation, + ), + SuccessUIConfig.Parser + ).orEmpty() + ) + ) + } + private fun canCreateExtraDocument(flowType: IssuanceFlowUiConfig): Boolean = flowType != IssuanceFlowUiConfig.NO_DOCUMENT } \ No newline at end of file diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentDetailsInteractor.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentDetailsInteractor.kt index 8033224e..5b43b116 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentDetailsInteractor.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentDetailsInteractor.kt @@ -22,9 +22,10 @@ import eu.europa.ec.commonfeature.ui.document_details.transformer.DocumentDetail import eu.europa.ec.corelogic.controller.DeleteAllDocumentsPartialState import eu.europa.ec.corelogic.controller.DeleteDocumentPartialState import eu.europa.ec.corelogic.controller.WalletCoreDocumentsController -import eu.europa.ec.corelogic.model.DocType import eu.europa.ec.corelogic.model.DocumentIdentifier import eu.europa.ec.corelogic.model.toDocumentIdentifier +import eu.europa.ec.eudi.wallet.document.DocumentId +import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.resourceslogic.provider.ResourceProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -44,12 +45,11 @@ sealed class DocumentDetailsInteractorDeleteDocumentPartialState { interface DocumentDetailsInteractor { fun getDocumentDetails( - documentId: String, + documentId: DocumentId, ): Flow fun deleteDocument( - documentId: String, - documentType: DocType + documentId: DocumentId ): Flow } @@ -62,13 +62,14 @@ class DocumentDetailsInteractorImpl( get() = resourceProvider.genericErrorMessage() override fun getDocumentDetails( - documentId: String, + documentId: DocumentId, ): Flow = flow { - val document = walletCoreDocumentsController.getDocumentById(id = documentId) - document?.let { + val document = walletCoreDocumentsController.getDocumentById(documentId = documentId) + as? IssuedDocument + document?.let { issuedDocument -> val itemUi = DocumentDetailsTransformer.transformToUiItem( - document = it, + document = issuedDocument, resourceProvider = resourceProvider, ) itemUi?.let { documentUi -> @@ -86,13 +87,13 @@ class DocumentDetailsInteractorImpl( } override fun deleteDocument( - documentId: String, - documentType: DocType + documentId: DocumentId ): Flow = flow { + val document = walletCoreDocumentsController.getDocumentById(documentId = documentId) val shouldDeleteAllDocuments: Boolean = - if (documentType.toDocumentIdentifier() == DocumentIdentifier.PID) { + if (document?.docType?.toDocumentIdentifier() == DocumentIdentifier.PID) { val allPidDocuments = walletCoreDocumentsController.getAllDocumentsByType(documentIdentifier = DocumentIdentifier.PID) diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentOfferInteractor.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentOfferInteractor.kt index f3bbc80b..6a515eb1 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentOfferInteractor.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/interactor/document/DocumentOfferInteractor.kt @@ -35,6 +35,8 @@ import eu.europa.ec.corelogic.model.toDocumentIdentifier import eu.europa.ec.eudi.wallet.issue.openid4vci.Offer.TxCodeSpec.InputMode import eu.europa.ec.resourceslogic.R import eu.europa.ec.resourceslogic.provider.ResourceProvider +import eu.europa.ec.resourceslogic.theme.values.ThemeColors +import eu.europa.ec.uilogic.component.AppIcons import eu.europa.ec.uilogic.config.ConfigNavigation import eu.europa.ec.uilogic.navigation.CommonScreens import eu.europa.ec.uilogic.navigation.helper.generateComposableArguments @@ -60,6 +62,10 @@ sealed class IssueDocumentsInteractorPartialState { val successRoute: String, ) : IssueDocumentsInteractorPartialState() + data class DeferredSuccess( + val successRoute: String, + ) : IssueDocumentsInteractorPartialState() + data class Failure(val errorMessage: String) : IssueDocumentsInteractorPartialState() data class UserAuthRequired( @@ -83,6 +89,8 @@ interface DocumentOfferInteractor { crypto: BiometricCrypto, resultHandler: DeviceAuthenticationResult ) + + fun resumeOpenId4VciWithAuthorization(uri: String) } class DocumentOfferInteractorImpl( @@ -208,25 +216,27 @@ class DocumentOfferInteractorImpl( ) IssueDocumentsInteractorPartialState.Success( - successRoute = buildIssuanceSuccessRoute( - resourceProvider.getString( + successRoute = buildGenericSuccessRoute( + type = IssuanceSuccessType.DEFAULT, + subtitle = resourceProvider.getString( R.string.issuance_document_offer_partial_success_subtitle, issuerName, nonIssuedDocsNames ), - navigation + navigation = navigation ) ) } is IssueDocumentsPartialState.Success -> { IssueDocumentsInteractorPartialState.Success( - successRoute = buildIssuanceSuccessRoute( - resourceProvider.getString( + successRoute = buildGenericSuccessRoute( + type = IssuanceSuccessType.DEFAULT, + subtitle = resourceProvider.getString( R.string.issuance_document_offer_success_subtitle, issuerName ), - navigation + navigation = navigation ) ) } @@ -237,6 +247,19 @@ class DocumentOfferInteractorImpl( resultHandler = response.resultHandler ) } + + is IssueDocumentsPartialState.DeferredSuccess -> { + IssueDocumentsInteractorPartialState.DeferredSuccess( + successRoute = buildGenericSuccessRoute( + type = IssuanceSuccessType.DEFERRED, + subtitle = resourceProvider.getString( + R.string.issuance_document_offer_deferred_success_subtitle, + issuerName + ), + navigation = navigation + ) + ) + } } }.collect { emit(it) @@ -277,27 +300,71 @@ class DocumentOfferInteractorImpl( } } - private fun buildIssuanceSuccessRoute(subtitle: String, navigation: ConfigNavigation): String { - val successScreenArguments = getSuccessScreenArguments(subtitle, navigation) + override fun resumeOpenId4VciWithAuthorization(uri: String) { + walletCoreDocumentsController.resumeOpenId4VciWithAuthorization(uri) + } + + private enum class IssuanceSuccessType { + DEFAULT, DEFERRED + } + + private fun buildGenericSuccessRoute( + type: IssuanceSuccessType, + subtitle: String, + navigation: ConfigNavigation + ): String { + val successScreenArguments = getSuccessScreenArguments(type, subtitle, navigation) return generateComposableNavigationLink( screen = CommonScreens.Success, arguments = successScreenArguments ) } - private fun getSuccessScreenArguments(subtitle: String, navigation: ConfigNavigation): String { + private fun getSuccessScreenArguments( + type: IssuanceSuccessType, + subtitle: String, + navigation: ConfigNavigation + ): String { + val (headerConfig, imageConfig, buttonText) = when (type) { + IssuanceSuccessType.DEFAULT -> Triple( + first = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.issuance_document_offer_success_title), + color = ThemeColors.success + ), + second = SuccessUIConfig.ImageConfig( + type = SuccessUIConfig.ImageConfig.Type.DEFAULT, + drawableRes = null, + tint = ThemeColors.success, + contentDescription = resourceProvider.getString(R.string.content_description_success) + ), + third = resourceProvider.getString(R.string.issuance_document_offer_success_primary_button_text) + ) + + IssuanceSuccessType.DEFERRED -> Triple( + first = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.issuance_document_offer_deferred_success_title), + color = ThemeColors.warning + ), + second = SuccessUIConfig.ImageConfig( + type = SuccessUIConfig.ImageConfig.Type.DRAWABLE, + drawableRes = AppIcons.ClockTimer.resourceId, + tint = ThemeColors.warning, + contentDescription = resourceProvider.getString(AppIcons.ClockTimer.contentDescriptionId) + ), + third = resourceProvider.getString(R.string.issuance_document_offer_deferred_success_primary_button_text) + ) + } + return generateComposableArguments( mapOf( SuccessUIConfig.serializedKeyName to uiSerializer.toBase64( SuccessUIConfig( - header = resourceProvider.getString(R.string.issuance_document_offer_success_title), + headerConfig = headerConfig, content = subtitle, - imageConfig = SuccessUIConfig.ImageConfig( - type = SuccessUIConfig.ImageConfig.Type.DEFAULT - ), + imageConfig = imageConfig, buttonConfig = listOf( SuccessUIConfig.ButtonConfig( - text = resourceProvider.getString(R.string.issuance_document_offer_success_primary_button_text), + text = buttonText, style = SuccessUIConfig.ButtonConfig.Style.PRIMARY, navigation = navigation ) diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/router/Graph.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/router/Graph.kt index 5dded3cf..c8fddac2 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/router/Graph.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/router/Graph.kt @@ -114,9 +114,6 @@ fun NavGraphBuilder.featureIssuanceGraph(navController: NavController) { navArgument("documentId") { type = NavType.StringType }, - navArgument("documentType") { - type = NavType.StringType - }, ) ) { DocumentDetailsScreen( @@ -128,7 +125,6 @@ fun NavGraphBuilder.featureIssuanceGraph(navController: NavController) { it.arguments?.getString("detailsType").orEmpty() ), it.arguments?.getString("documentId").orEmpty(), - it.arguments?.getString("documentType").orEmpty(), ) } ) diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentScreen.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentScreen.kt index 3af5f525..234df946 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentScreen.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentScreen.kt @@ -139,7 +139,10 @@ fun AddDocumentScreen( ) ) { when (it?.action) { - CoreActions.VCI_RESUME_ACTION -> viewModel.setEvent(Event.OnResumeIssuance) + CoreActions.VCI_RESUME_ACTION -> it.extras?.getString("uri")?.let { link -> + viewModel.setEvent(Event.OnResumeIssuance(link)) + } + CoreActions.VCI_DYNAMIC_PRESENTATION -> it.extras?.getString("uri")?.let { link -> viewModel.setEvent(Event.OnDynamicPresentation(link)) } diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentViewModel.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentViewModel.kt index aa0b3ccd..22485d4f 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentViewModel.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/add/AddDocumentViewModel.kt @@ -75,7 +75,7 @@ sealed class Event : ViewEvent { data class Init(val deepLink: Uri?) : Event() data object Pop : Event() data object OnPause : Event() - data object OnResumeIssuance : Event() + data class OnResumeIssuance(val uri: String) : Event() data class OnDynamicPresentation(val uri: String) : Event() data object Finish : Event() data object DismissError : Event() @@ -149,8 +149,11 @@ class AddDocumentViewModel( } } - is Event.OnResumeIssuance -> setState { - copy(isLoading = true) + is Event.OnResumeIssuance -> { + setState { + copy(isLoading = true) + } + addDocumentInteractor.resumeOpenId4VciWithAuthorization(event.uri) } is Event.OnDynamicPresentation -> { @@ -262,11 +265,25 @@ class AddDocumentViewModel( isLoading = false ) } - navigateToSuccessScreen( + navigateToIssuanceSuccessScreen( documentId = response.documentId ) } + is IssueDocumentPartialState.DeferredSuccess -> { + setState { + copy( + error = null, + isLoading = false + ) + } + navigateToGenericSuccessScreen( + route = addDocumentInteractor.buildGenericSuccessRouteForDeferred( + flowType + ) + ) + } + is IssueDocumentPartialState.UserAuthRequired -> { addDocumentInteractor.handleUserAuth( context = context, @@ -326,7 +343,7 @@ class AddDocumentViewModel( } } - private fun navigateToSuccessScreen(documentId: String) { + private fun navigateToIssuanceSuccessScreen(documentId: String) { setEffect { Effect.Navigation.SwitchScreen( screenRoute = generateComposableNavigationLink( @@ -343,6 +360,15 @@ class AddDocumentViewModel( } } + private fun navigateToGenericSuccessScreen(route: String) { + setEffect { + Effect.Navigation.SwitchScreen( + screenRoute = route, + inclusive = true + ) + } + } + private fun navigateToDashboardScreen() { setEffect { Effect.Navigation.SwitchScreen( diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/code/DocumentOfferCodeViewModel.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/code/DocumentOfferCodeViewModel.kt index 94ada70b..cc99fd97 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/code/DocumentOfferCodeViewModel.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/code/DocumentOfferCodeViewModel.kt @@ -142,6 +142,16 @@ class DocumentOfferCodeViewModel( goToSuccessScreen(route = response.successRoute) } + is IssueDocumentsInteractorPartialState.DeferredSuccess -> { + setState { + copy( + isLoading = LoadingType.NONE, + error = null, + ) + } + goToSuccessScreen(route = response.successRoute) + } + is IssueDocumentsInteractorPartialState.UserAuthRequired -> { documentOfferInteractor.handleUserAuthentication( context = context, diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsScreen.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsScreen.kt index 01153943..79022db8 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsScreen.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsScreen.kt @@ -47,6 +47,7 @@ import androidx.navigation.NavController import eu.europa.ec.businesslogic.util.safeLet import eu.europa.ec.commonfeature.config.IssuanceFlowUiConfig import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.ui.document_details.DetailsContent import eu.europa.ec.corelogic.model.DocumentIdentifier import eu.europa.ec.resourceslogic.R @@ -345,7 +346,8 @@ private fun IssuanceDocumentDetailsScreenPreview() { documentExpirationDateFormatted = "30 Mar 2050", documentHasExpired = false, documentImage = "image3", - documentDetails = emptyList() + documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued, ), headerData = HeaderData( title = "Title", @@ -388,7 +390,8 @@ private fun DashboardDocumentDetailsScreenPreview() { documentExpirationDateFormatted = "30 Mar 2050", documentHasExpired = false, documentImage = "image3", - documentDetails = emptyList() + documentDetails = emptyList(), + documentIssuanceState = DocumentUiIssuanceState.Issued, ), headerData = HeaderData( title = "Title", diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsViewModel.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsViewModel.kt index 500efbf7..b6ff3695 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsViewModel.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/details/DocumentDetailsViewModel.kt @@ -19,7 +19,7 @@ package eu.europa.ec.issuancefeature.ui.document.details import androidx.lifecycle.viewModelScope import eu.europa.ec.commonfeature.config.IssuanceFlowUiConfig import eu.europa.ec.commonfeature.model.DocumentUi -import eu.europa.ec.corelogic.model.DocType +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.issuancefeature.interactor.document.DocumentDetailsInteractor import eu.europa.ec.issuancefeature.interactor.document.DocumentDetailsInteractorDeleteDocumentPartialState import eu.europa.ec.issuancefeature.interactor.document.DocumentDetailsInteractorPartialState @@ -92,8 +92,7 @@ sealed class Effect : ViewSideEffect { class DocumentDetailsViewModel( private val documentDetailsInteractor: DocumentDetailsInteractor, @InjectedParam private val detailsType: IssuanceFlowUiConfig, - @InjectedParam private val documentId: String, - @InjectedParam private val documentType: DocType, + @InjectedParam private val documentId: DocumentId, ) : MviViewModel() { override fun setInitialState(): State = State( detailsType = detailsType, @@ -205,8 +204,7 @@ class DocumentDetailsViewModel( viewModelScope.launch { documentDetailsInteractor.deleteDocument( - documentId = documentId, - documentType = documentType + documentId = documentId ).collect { response -> when (response) { is DocumentDetailsInteractorDeleteDocumentPartialState.AllDocumentsDeleted -> { diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferScreen.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferScreen.kt index c7b36630..16940e84 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferScreen.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferScreen.kt @@ -144,7 +144,10 @@ fun DocumentOfferScreen( ) ) { when (it?.action) { - CoreActions.VCI_RESUME_ACTION -> viewModel.setEvent(Event.OnResumeIssuance) + CoreActions.VCI_RESUME_ACTION -> it.extras?.getString("uri")?.let { link -> + viewModel.setEvent(Event.OnResumeIssuance(link)) + } + CoreActions.VCI_DYNAMIC_PRESENTATION -> it.extras?.getString("uri")?.let { link -> viewModel.setEvent(Event.OnDynamicPresentation(link)) } diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferViewModel.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferViewModel.kt index 7a9bbf11..ad4968cc 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferViewModel.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/document/offer/DocumentOfferViewModel.kt @@ -69,7 +69,7 @@ sealed class Event : ViewEvent { data class Init(val deepLink: Uri?) : Event() data object Pop : Event() data object OnPause : Event() - data object OnResumeIssuance : Event() + data class OnResumeIssuance(val uri: String) : Event() data class OnDynamicPresentation(val uri: String) : Event() data object DismissError : Event() @@ -194,8 +194,11 @@ class DocumentOfferViewModel( } } - is Event.OnResumeIssuance -> setState { - copy(isLoading = true) + is Event.OnResumeIssuance -> { + setState { + copy(isLoading = true) + } + documentOfferInteractor.resumeOpenId4VciWithAuthorization(event.uri) } is Event.OnDynamicPresentation -> { @@ -343,6 +346,17 @@ class DocumentOfferViewModel( goToSuccessScreen(route = response.successRoute) } + is IssueDocumentsInteractorPartialState.DeferredSuccess -> { + setState { + copy( + isLoading = false, + error = null, + ) + } + + goToSuccessScreen(route = response.successRoute) + } + is IssueDocumentsInteractorPartialState.UserAuthRequired -> { documentOfferInteractor.handleUserAuthentication( context = context, diff --git a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/success/SuccessViewModel.kt b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/success/SuccessViewModel.kt index a949695d..f7063c5e 100644 --- a/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/success/SuccessViewModel.kt +++ b/issuance-feature/src/main/java/eu/europa/ec/issuancefeature/ui/success/SuccessViewModel.kt @@ -18,7 +18,7 @@ package eu.europa.ec.issuancefeature.ui.success import androidx.lifecycle.viewModelScope import eu.europa.ec.commonfeature.config.IssuanceFlowUiConfig -import eu.europa.ec.corelogic.model.toDocumentIdentifier +import eu.europa.ec.eudi.wallet.document.DocumentId import eu.europa.ec.eudi.wallet.document.IssuedDocument import eu.europa.ec.issuancefeature.interactor.SuccessFetchDocumentByIdPartialState import eu.europa.ec.issuancefeature.interactor.SuccessInteractor @@ -59,7 +59,7 @@ sealed class Effect : ViewSideEffect { class SuccessViewModel( private val interactor: SuccessInteractor, @InjectedParam private val flowType: IssuanceFlowUiConfig, - @InjectedParam private val documentId: String, + @InjectedParam private val documentId: DocumentId, ) : MviViewModel() { override fun setInitialState(): State { @@ -70,7 +70,7 @@ class SuccessViewModel( when (event) { is Event.Init -> { viewModelScope.launch { - interactor.fetchDocumentById(id = documentId).collect { response -> + interactor.fetchDocumentById(documentId = documentId).collect { response -> when (response) { is SuccessFetchDocumentByIdPartialState.Failure -> { @@ -103,8 +103,7 @@ class SuccessViewModel( "detailsType" to IssuanceFlowUiConfig.fromIssuanceFlowUiConfig( flowType ), - "documentId" to document.id, - "documentType" to document.toDocumentIdentifier().docType, + "documentId" to document.id ) ) ) diff --git a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/TestSuccessInteractor.kt b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/TestSuccessInteractor.kt index e419848f..6df76847 100644 --- a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/TestSuccessInteractor.kt +++ b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/TestSuccessInteractor.kt @@ -28,6 +28,7 @@ import eu.europa.ec.testfeature.mockedGenericErrorMessage import eu.europa.ec.testlogic.extension.runFlowTest import eu.europa.ec.testlogic.extension.runTest import eu.europa.ec.testlogic.rule.CoroutineTestRule +import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule @@ -35,7 +36,6 @@ import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.whenever -import kotlin.test.assertEquals class TestSuccessInteractor { diff --git a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestAddDocumentInteractor.kt b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestAddDocumentInteractor.kt index d5794a41..31b6df98 100644 --- a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestAddDocumentInteractor.kt +++ b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestAddDocumentInteractor.kt @@ -42,6 +42,8 @@ import eu.europa.ec.testlogic.extension.runFlowTest import eu.europa.ec.testlogic.extension.runTest import eu.europa.ec.testlogic.extension.toFlow import eu.europa.ec.testlogic.rule.CoroutineTestRule +import eu.europa.ec.uilogic.serializer.UiSerializer +import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule @@ -53,7 +55,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.test.assertEquals class TestAddDocumentInteractor { @@ -69,6 +70,9 @@ class TestAddDocumentInteractor { @Mock private lateinit var resourceProvider: ResourceProvider + @Mock + private lateinit var uiSerializer: UiSerializer + @Mock private lateinit var context: Context @@ -89,6 +93,7 @@ class TestAddDocumentInteractor { walletCoreDocumentsController = walletCoreDocumentsController, deviceAuthenticationInteractor = deviceAuthenticationInteractor, resourceProvider = resourceProvider, + uiSerializer = uiSerializer, ) crypto = BiometricCrypto(cryptoObject = null) @@ -331,7 +336,7 @@ class TestAddDocumentInteractor { // Case 3: // 1. deviceAuthenticationInteractor.getBiometricsAvailability returns: - // BiometricsAvailability.Failure + // BiometricsAvailability.Failed // Case 3 Expected Result: // resultHandler.onAuthenticationFailure called once. diff --git a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestDocumentDetailsInteractor.kt b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestDocumentDetailsInteractor.kt index ac4f2d3a..67a4101c 100644 --- a/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestDocumentDetailsInteractor.kt +++ b/issuance-feature/src/test/java/eu/europa/ec/issuancefeature/interactor/document/TestDocumentDetailsInteractor.kt @@ -17,6 +17,7 @@ package eu.europa.ec.issuancefeature.interactor.document import eu.europa.ec.commonfeature.model.DocumentUi +import eu.europa.ec.commonfeature.model.DocumentUiIssuanceState import eu.europa.ec.commonfeature.ui.document_details.model.DocumentDetailsUi import eu.europa.ec.commonfeature.util.TestsData import eu.europa.ec.commonfeature.util.TestsData.mockedBasicMdlUi @@ -33,12 +34,10 @@ import eu.europa.ec.testfeature.mockedEmptyPid import eu.europa.ec.testfeature.mockedExceptionWithMessage import eu.europa.ec.testfeature.mockedExceptionWithNoMessage import eu.europa.ec.testfeature.mockedGenericErrorMessage -import eu.europa.ec.testfeature.mockedMdlDocType import eu.europa.ec.testfeature.mockedMdlId import eu.europa.ec.testfeature.mockedMdlWithBasicFields import eu.europa.ec.testfeature.mockedOldestPidId import eu.europa.ec.testfeature.mockedOldestPidWithBasicFields -import eu.europa.ec.testfeature.mockedPidDocType import eu.europa.ec.testfeature.mockedPidId import eu.europa.ec.testfeature.mockedPidNameSpace import eu.europa.ec.testfeature.mockedPidWithBasicFields @@ -48,6 +47,7 @@ import eu.europa.ec.testlogic.extension.runTest import eu.europa.ec.testlogic.extension.toFlow import eu.europa.ec.testlogic.rule.CoroutineTestRule import eu.europa.ec.uilogic.component.InfoTextWithNameAndValueData +import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule @@ -57,7 +57,6 @@ import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.whenever -import kotlin.test.assertEquals class TestDocumentDetailsInteractor { @@ -157,7 +156,7 @@ class TestDocumentDetailsInteractor { // 1. walletCoreDocumentsController.getDocumentById() returns an empty PID document. // Case 3 Expected Result: - // DocumentDetailsInteractorPartialState.Failure state, + // DocumentDetailsInteractorPartialState.Failed state, // with the generic error message. @Test fun `Given Case 3, When getDocumentDetails is called, Then Case 3 Expected Result is returned`() { @@ -184,7 +183,7 @@ class TestDocumentDetailsInteractor { // 1. walletCoreDocumentsController.getDocumentById() returns null. // Case 4 Expected Result: - // DocumentDetailsInteractorPartialState.Failure state, + // DocumentDetailsInteractorPartialState.Failed state, // with the generic error message. @Test fun `Given Case 4, When getDocumentDetails is called, Then Case 4 Expected Result is returned`() { @@ -257,7 +256,8 @@ class TestDocumentDetailsInteractor { ) ) ), - userFullName = "" + userFullName = "", + documentIssuanceState = DocumentUiIssuanceState.Issued ) ), awaitItem() @@ -270,7 +270,7 @@ class TestDocumentDetailsInteractor { // 1. walletCoreDocumentsController.getDocumentById() throws an exception with a message. // Case 6 Expected Result: - // DocumentDetailsInteractorPartialState.Failure state, + // DocumentDetailsInteractorPartialState.Failed state, // with the exception's localized message. @Test fun `Given Case 6, When getDocumentDetails is called, Then Case 6 Expected Result is returned`() { @@ -298,7 +298,7 @@ class TestDocumentDetailsInteractor { // 1. walletCoreDocumentsController.getDocumentById() throws an exception with no message. // Case 7 Expected Result: - // DocumentDetailsInteractorPartialState.Failure state, + // DocumentDetailsInteractorPartialState.Failed state, // with the generic error message. @Test fun `Given Case 7, When getDocumentDetails is called, Then Case 7 Expected Result is returned`() { @@ -329,7 +329,8 @@ class TestDocumentDetailsInteractor { // 1. A documentId and document is PID. // 2. walletCoreDocumentsController.getAllDocuments() returns 1 Document and it is PID. - // 3. walletCoreDocumentsController.deleteAllDocuments() returns Failure. + // 3. walletCoreDocumentsController.getDocumentById returns that PID Document. + // 4. walletCoreDocumentsController.deleteAllDocuments() returns Failed. @Test fun `Given Case 1, When deleteDocument is called, Then it returns Failure with failure's error message`() { coroutineRule.runTest { @@ -344,11 +345,13 @@ class TestDocumentDetailsInteractor { errorMessage = mockedPlainFailureMessage ) ) + mockGetDocumentByIdCall( + response = mockedPidWithBasicFields + ) // When interactor.deleteDocument( documentId = mockedPidId, - documentType = mockedPidDocType ).runFlowTest { // Then assertEquals( @@ -365,7 +368,8 @@ class TestDocumentDetailsInteractor { // 1. A documentId and document is PID. // 2. walletCoreDocumentsController.getAllDocuments() returns 1 Document and it is PID. - // 3. walletCoreDocumentsController.deleteAllDocuments() returns Success. + // 3. walletCoreDocumentsController.getDocumentById returns that PID Document. + // 4. walletCoreDocumentsController.deleteAllDocuments() returns Success. @Test fun `Given Case 2, When deleteDocument is called, Then it returns AllDocumentsDeleted`() { coroutineRule.runTest { @@ -376,11 +380,13 @@ class TestDocumentDetailsInteractor { ) ) mockDeleteAllDocumentsCall(response = DeleteAllDocumentsPartialState.Success) + mockGetDocumentByIdCall( + response = mockedPidWithBasicFields + ) // When interactor.deleteDocument( documentId = mockedPidId, - documentType = mockedPidDocType ).runFlowTest { // Then assertEquals( @@ -395,8 +401,8 @@ class TestDocumentDetailsInteractor { // 1. A documentId and document is PID. // 2. walletCoreDocumentsController.getAllDocuments() returns more than 1 PIDs - // AND the documentId we are about to delete IS the one of the oldest PID. - // 3. walletCoreDocumentsController.deleteAllDocuments() returns Success. + // 3. walletCoreDocumentsController.getDocumentById returns the oldest Document. + // 4. walletCoreDocumentsController.deleteAllDocuments() returns Success. @Test fun `Given Case 3, When deleteDocument is called, Then it returns AllDocumentsDeleted`() { coroutineRule.runTest { @@ -409,11 +415,13 @@ class TestDocumentDetailsInteractor { ) ) mockDeleteAllDocumentsCall(response = DeleteAllDocumentsPartialState.Success) + mockGetDocumentByIdCall( + response = mockedOldestPidWithBasicFields + ) // When interactor.deleteDocument( documentId = mockedOldestPidId, - documentType = mockedPidDocType ).runFlowTest { // Then assertEquals( @@ -446,7 +454,6 @@ class TestDocumentDetailsInteractor { // When interactor.deleteDocument( documentId = mockedPidId, - documentType = mockedPidDocType ).runFlowTest { // Then assertEquals( @@ -460,7 +467,7 @@ class TestDocumentDetailsInteractor { // Case 5: // 1. A documentId and document is mDL. - // 2. walletCoreDocumentsController.deleteDocument() returns Failure. + // 2. walletCoreDocumentsController.deleteDocument() returns Failed. @Test fun `Given Case 5, When deleteDocument is called, Then it returns Failure with failure's error message`() { coroutineRule.runTest { @@ -474,7 +481,6 @@ class TestDocumentDetailsInteractor { // When interactor.deleteDocument( documentId = mockedMdlId, - documentType = mockedMdlDocType ).runFlowTest { // Then assertEquals( @@ -500,7 +506,6 @@ class TestDocumentDetailsInteractor { // When interactor.deleteDocument( documentId = mockedMdlId, - documentType = mockedMdlDocType ).runFlowTest { // Then assertEquals( @@ -525,7 +530,6 @@ class TestDocumentDetailsInteractor { // When interactor.deleteDocument( documentId = mockedMdlId, - documentType = mockedMdlDocType ).runFlowTest { // Then assertEquals( @@ -552,7 +556,6 @@ class TestDocumentDetailsInteractor { // When interactor.deleteDocument( documentId = mockedMdlId, - documentType = mockedMdlDocType ).runFlowTest { // Then assertEquals( diff --git a/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/interactor/PresentationRequestInteractor.kt b/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/interactor/PresentationRequestInteractor.kt index 8c526afb..790b8182 100644 --- a/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/interactor/PresentationRequestInteractor.kt +++ b/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/interactor/PresentationRequestInteractor.kt @@ -77,7 +77,7 @@ class PresentationRequestInteractorImpl( ) } else { val requestDataUi = RequestTransformer.transformToUiItems( - storageDocuments = walletCoreDocumentsController.getAllDocuments(), + storageDocuments = walletCoreDocumentsController.getAllIssuedDocuments(), requestDocuments = response.requestData, requiredFieldsTitle = resourceProvider.getString(R.string.request_required_fields_title), resourceProvider = resourceProvider diff --git a/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/ui/loading/PresentationLoadingViewModel.kt b/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/ui/loading/PresentationLoadingViewModel.kt index 92eb53d0..983ed59e 100644 --- a/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/ui/loading/PresentationLoadingViewModel.kt +++ b/presentation-feature/src/main/java/eu/europa/ec/presentationfeature/ui/loading/PresentationLoadingViewModel.kt @@ -148,7 +148,9 @@ class PresentationLoadingViewModel( return mapOf( SuccessUIConfig.serializedKeyName to uiSerializer.toBase64( SuccessUIConfig( - header = resourceProvider.getString(R.string.loading_success_config_title), + headerConfig = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.loading_success_config_title) + ), content = resourceProvider.getString( R.string.presentation_loading_success_config_subtitle, interactor.verifierName diff --git a/presentation-feature/src/test/java/eu/europa/ec/presentationfeature/interactor/TestPresentationLoadingInteractor.kt b/presentation-feature/src/test/java/eu/europa/ec/presentationfeature/interactor/TestPresentationLoadingInteractor.kt index 92d22404..7eb5748f 100644 --- a/presentation-feature/src/test/java/eu/europa/ec/presentationfeature/interactor/TestPresentationLoadingInteractor.kt +++ b/presentation-feature/src/test/java/eu/europa/ec/presentationfeature/interactor/TestPresentationLoadingInteractor.kt @@ -85,10 +85,10 @@ class TestPresentationLoadingInteractor { // Case 1: // 1. walletCorePresentationController.events emits: - // WalletCorePartialState.Failure, with an error message. + // WalletCorePartialState.Failed, with an error message. // Case 1 Expected Result: - // PresentationLoadingObserveResponsePartialState.Failure state, with the same error message. + // PresentationLoadingObserveResponsePartialState.Failed state, with the same error message. @Test fun `Given Case 1, When observeResponse is called, Then Case 1 Expected Result is returned`() { @@ -257,7 +257,7 @@ class TestPresentationLoadingInteractor { // Case 3: // 1. deviceAuthenticationInteractor.getBiometricsAvailability returns: - // BiometricsAvailability.Failure + // BiometricsAvailability.Failed // Case 3 Expected Result: // resultHandler.onAuthenticationFailure called once. diff --git a/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/interactor/ProximityRequestInteractor.kt b/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/interactor/ProximityRequestInteractor.kt index a9c0c134..2f9412de 100644 --- a/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/interactor/ProximityRequestInteractor.kt +++ b/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/interactor/ProximityRequestInteractor.kt @@ -77,7 +77,7 @@ class ProximityRequestInteractorImpl( ) } else { val requestDataUi = RequestTransformer.transformToUiItems( - storageDocuments = walletCoreDocumentsController.getAllDocuments(), + storageDocuments = walletCoreDocumentsController.getAllIssuedDocuments(), requestDocuments = response.requestData, requiredFieldsTitle = resourceProvider.getString(R.string.request_required_fields_title), resourceProvider = resourceProvider diff --git a/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/ui/loading/ProximityLoadingViewModel.kt b/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/ui/loading/ProximityLoadingViewModel.kt index 364a10d5..ee04d72f 100644 --- a/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/ui/loading/ProximityLoadingViewModel.kt +++ b/proximity-feature/src/main/java/eu/europa/ec/proximityfeature/ui/loading/ProximityLoadingViewModel.kt @@ -137,7 +137,9 @@ class ProximityLoadingViewModel( return mapOf( SuccessUIConfig.serializedKeyName to uiSerializer.toBase64( SuccessUIConfig( - header = resourceProvider.getString(R.string.loading_success_config_title), + headerConfig = SuccessUIConfig.HeaderConfig( + title = resourceProvider.getString(R.string.loading_success_config_title) + ), content = resourceProvider.getString( R.string.presentation_loading_success_config_subtitle, interactor.verifierName diff --git a/proximity-feature/src/test/java/eu/europa/ec/proximityfeature/interactor/TestProximityRequestInteractor.kt b/proximity-feature/src/test/java/eu/europa/ec/proximityfeature/interactor/TestProximityRequestInteractor.kt index f92d3ed8..445e0908 100644 --- a/proximity-feature/src/test/java/eu/europa/ec/proximityfeature/interactor/TestProximityRequestInteractor.kt +++ b/proximity-feature/src/test/java/eu/europa/ec/proximityfeature/interactor/TestProximityRequestInteractor.kt @@ -133,7 +133,7 @@ class TestProximityRequestInteractor { // TransferEventPartialState.Error, with an error message. // Case 2 Expected Result: - // ProximityRequestInteractorPartialState.Failure state, with the same error message. + // ProximityRequestInteractorPartialState.Failed state, with the same error message. @Test fun `Given Case 2, When getRequestDocuments is called, Then Case 2 Expected Result is returned`() { coroutineRule.runTest { @@ -326,7 +326,7 @@ class TestProximityRequestInteractor { fun `Given Case 7, When getRequestDocuments is called, Then Case 7 Expected Result is returned`() { coroutineRule.runTest { // Given - mockGetAllDocumentsCall( + mockGetAllIssuedDocumentsCall( response = listOf(mockedPidWithBasicFields) ) whenever(resourceProvider.getString(R.string.request_required_fields_title)) @@ -382,7 +382,7 @@ class TestProximityRequestInteractor { fun `Given Case 8, When getRequestDocuments is called, Then Case 8 Expected Result is returned`() { coroutineRule.runTest { // Given - mockGetAllDocumentsCall( + mockGetAllIssuedDocumentsCall( response = listOf(mockedMdlWithBasicFields) ) whenever(resourceProvider.getString(R.string.request_required_fields_title)) @@ -438,7 +438,7 @@ class TestProximityRequestInteractor { fun `Given Case 9, When getRequestDocuments is called, Then Case 9 Expected Result is returned`() { coroutineRule.runTest { // Given - mockGetAllDocumentsCall( + mockGetAllIssuedDocumentsCall( response = listOf( mockedMdlWithBasicFields, mockedPidWithBasicFields @@ -499,7 +499,7 @@ class TestProximityRequestInteractor { fun `Given Case 10, When getRequestDocuments is called, Then Case 10 Expected Result is returned`() { coroutineRule.runTest { // Given - mockGetAllDocumentsCall( + mockGetAllIssuedDocumentsCall( response = listOf( mockedPidWithBasicFields, mockedMdlWithBasicFields @@ -550,10 +550,10 @@ class TestProximityRequestInteractor { // 1. a list of a PID RequestDocument, with some basic fields, // 2. a not null String for verifier name, // 3. true for verifierIsTrusted, - // 4. walletCoreDocumentsController.getAllDocuments() throws an exception with a message. + // 4. walletCoreDocumentsController.getAllIssuedDocuments() throws an exception with a message. // Case 11 Expected Result: - // ProximityRequestInteractorPartialState.Failure state, with: + // ProximityRequestInteractorPartialState.Failed state, with: // 1. exception's localized message. @Test fun `Given Case 11, When getRequestDocuments is called, Then Case 11 Expected Result is returned`() { @@ -568,7 +568,7 @@ class TestProximityRequestInteractor { verifierIsTrusted = mockedVerifierIsTrusted ) ) - whenever(walletCoreDocumentsController.getAllDocuments()) + whenever(walletCoreDocumentsController.getAllIssuedDocuments()) .thenThrow(mockedExceptionWithMessage) // When @@ -591,10 +591,10 @@ class TestProximityRequestInteractor { // 1. a list of a PID RequestDocument, with some basic fields, // 2. a not null String for verifier name, // 3. true for verifierIsTrusted, - // 4. walletCoreDocumentsController.getAllDocuments() throws an exception with no message. + // 4. walletCoreDocumentsController.getAllIssuedDocuments() throws an exception with no message. // Case 12 Expected Result: - // ProximityRequestInteractorPartialState.Failure state, with: + // ProximityRequestInteractorPartialState.Failed state, with: // 1. the generic error message. @Test fun `Given Case 12, When getRequestDocuments is called, Then Case 12 Expected Result is returned`() { @@ -609,7 +609,7 @@ class TestProximityRequestInteractor { verifierIsTrusted = mockedVerifierIsTrusted ) ) - whenever(walletCoreDocumentsController.getAllDocuments()) + whenever(walletCoreDocumentsController.getAllIssuedDocuments()) .thenThrow(mockedExceptionWithNoMessage) // When @@ -706,8 +706,8 @@ class TestProximityRequestInteractor { ) } - private fun mockGetAllDocumentsCall(response: List) { - whenever(walletCoreDocumentsController.getAllDocuments()) + private fun mockGetAllIssuedDocumentsCall(response: List) { + whenever(walletCoreDocumentsController.getAllIssuedDocuments()) .thenReturn(response) } //endregion diff --git a/resources-logic/src/main/res/drawable/ic_clock_timer.xml b/resources-logic/src/main/res/drawable/ic_clock_timer.xml new file mode 100644 index 00000000..29b1b56f --- /dev/null +++ b/resources-logic/src/main/res/drawable/ic_clock_timer.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources-logic/src/main/res/values/strings.xml b/resources-logic/src/main/res/values/strings.xml index 69490659..e69b904b 100644 --- a/resources-logic/src/main/res/values/strings.xml +++ b/resources-logic/src/main/res/values/strings.xml @@ -33,6 +33,11 @@ - Scan QR Place the QR code within the frame to start scanning + In progress + Delete %1$s? + You will not be able to use your %1$s for future verifications + yes + no User image @@ -43,6 +48,7 @@ Close Warning Error + Error Delete Touch id QR @@ -60,6 +66,9 @@ Edit Verified Message + Success + Clock timer + Arrow right National ID @@ -78,11 +87,6 @@ Your device does not support biometric authentication Something went wrong initializing biometric authentication. Please try again - - Success icon - Success - You successfully set the quick pin - Invalid quick pin Only numerical values are allowed @@ -150,8 +154,11 @@ Expired\ on @string/generic_dash + Pending + Issuance failed SHOW QR / TAP ADD DOC + More options Change quick pin Scan a QR code @@ -161,6 +168,14 @@ @string/generic_enable_capitalized @string/generic_cancel_capitalized + @string/generic_bottom_sheet_delete_title + @string/generic_bottom_sheet_delete_subtitle + @string/generic_bottom_sheet_delete_primary_button_text + @string/generic_bottom_sheet_delete_secondary_button_text + + Documents issued + New documents have been added to your wallet. + Camera permission not provided\nOpen App Settings @@ -171,10 +186,10 @@ yes no - Delete %1$s? - You will not be able to use your %1$s for future verifications - yes - no + @string/generic_bottom_sheet_delete_title + @string/generic_bottom_sheet_delete_subtitle + @string/generic_bottom_sheet_delete_primary_button_text + @string/generic_bottom_sheet_delete_secondary_button_text Show QR or Tap @@ -194,6 +209,9 @@ Add document Select a document to add in your EUDI Wallet User authentication is required + @string/generic_deferred_success_title + Your document has been requested. You will be notified when it has been issued to your wallet. + @string/generic_ok Or SCAN QR @@ -216,9 +234,12 @@ ISSUE @string/generic_cancel_capitalized @string/generic_success + @string/generic_deferred_success_title Your documents from %1$s have been successfully issued. Your documents from %1$s have been successfully issued except %2$s + Your documents from %1$s have been requested. You will be notified when they have been issued to your wallet. @string/generic_continue_capitalized + @string/generic_ok Wallet needs to be activated first with a National ID Invalid transaction code format. Length should be between %1$d to %2$d digits and only numeric characters are allowed. Unable to fetch and process the credential offer diff --git a/test-feature/src/main/java/eu/europa/ec/testfeature/Constants.kt b/test-feature/src/main/java/eu/europa/ec/testfeature/Constants.kt index 97ebcfa9..16c77d27 100644 --- a/test-feature/src/main/java/eu/europa/ec/testfeature/Constants.kt +++ b/test-feature/src/main/java/eu/europa/ec/testfeature/Constants.kt @@ -663,6 +663,8 @@ val mockedFullPid = IssuedDocument( ) ) +val mockedMainPid = mockedFullPid + val mockedPidWithBasicFields = mockedFullPid.copy( nameSpacedData = mapOf( mockedPidNameSpace to mockedPidBasicFields diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/AppIcons.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/AppIcons.kt index a9657921..2eb59988 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/AppIcons.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/AppIcons.kt @@ -20,7 +20,9 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.runtime.Stable @@ -86,6 +88,12 @@ object AppIcons { imageVector = null ) + val ErrorFilled: IconData = IconData( + resourceId = null, + contentDescriptionId = R.string.content_description_error_icon, + imageVector = Icons.Default.Info + ) + val Delete: IconData = IconData( resourceId = R.drawable.ic_delete, contentDescriptionId = R.string.content_description_delete_icon, @@ -187,4 +195,16 @@ object AppIcons { contentDescriptionId = R.string.content_description_message, imageVector = null ) + + val ClockTimer: IconData = IconData( + resourceId = R.drawable.ic_clock_timer, + contentDescriptionId = R.string.content_description_clock_timer, + imageVector = null + ) + + val KeyboardArrowRight: IconData = IconData( + resourceId = null, + contentDescriptionId = R.string.content_description_arrow_right, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight + ) } \ No newline at end of file diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/ScalableText.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/ScalableText.kt index b69b1e99..5c1c0fc7 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/ScalableText.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/ScalableText.kt @@ -41,10 +41,14 @@ fun ScalableText( modifier: Modifier = Modifier, maxLines: Int = 1, ) { - var style by remember { mutableStateOf(textStyle) } - var readyToDraw by remember { mutableStateOf(false) } + val style by remember(text) { mutableStateOf(textStyle) } + var readyToDraw by remember(text) { mutableStateOf(false) } + var textSize by remember(text) { mutableStateOf(textStyle.fontSize) } Text( + text = text, + style = style.copy(fontSize = textSize), + maxLines = maxLines, modifier = modifier.then( Modifier .drawWithContent { @@ -53,12 +57,9 @@ fun ScalableText( } } ), - text = text, - style = style, - maxLines = maxLines, onTextLayout = { textLayoutResult -> if (!readyToDraw && textLayoutResult.hasVisualOverflow) { - style = style.copy(fontSize = style.fontSize * 0.9) + textSize *= 0.9 // Reduce the text size } else { readyToDraw = true } diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/wrap/WrapModalBottomSheet.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/wrap/WrapModalBottomSheet.kt index cc479667..fadc553d 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/component/wrap/WrapModalBottomSheet.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/component/wrap/WrapModalBottomSheet.kt @@ -17,11 +17,16 @@ package eu.europa.ec.uilogic.component.wrap import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -29,17 +34,31 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp +import eu.europa.ec.resourceslogic.theme.values.backgroundDefault import eu.europa.ec.resourceslogic.theme.values.backgroundPaper import eu.europa.ec.resourceslogic.theme.values.textPrimaryDark import eu.europa.ec.resourceslogic.theme.values.textSecondaryDark +import eu.europa.ec.uilogic.component.AppIcons +import eu.europa.ec.uilogic.component.IconData import eu.europa.ec.uilogic.component.preview.PreviewTheme import eu.europa.ec.uilogic.component.preview.ThemeModePreviews +import eu.europa.ec.uilogic.component.utils.SIZE_MEDIUM +import eu.europa.ec.uilogic.component.utils.SIZE_SMALL import eu.europa.ec.uilogic.component.utils.SPACING_EXTRA_LARGE import eu.europa.ec.uilogic.component.utils.SPACING_LARGE import eu.europa.ec.uilogic.component.utils.VSpacer +import eu.europa.ec.uilogic.extension.throttledClickable + +data class OptionListItemUi( + val text: String, + val icon: IconData = AppIcons.KeyboardArrowRight, + val onClick: () -> Unit +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,7 +81,7 @@ fun WrapModalBottomSheet( } @Composable -fun SheetContent( +fun GenericBaseSheetContent( title: String, bodyContent: @Composable () -> Unit ) { @@ -85,7 +104,7 @@ fun SheetContent( } @Composable -fun SheetContent( +fun GenericBaseSheetContent( titleContent: @Composable () -> Unit, bodyContent: @Composable () -> Unit, ) { @@ -116,40 +135,126 @@ fun DialogBottomSheet( onPositiveClick: () -> Unit? = {}, onNegativeClick: () -> Unit? = {} ) { - SheetContent(title = title) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.textSecondaryDark - ) - ) - VSpacer.Large() - positiveButtonText?.let { - WrapPrimaryButton( - onClick = { onPositiveClick.invoke() }, - modifier = Modifier.fillMaxWidth(), - enabled = true - ) { - Text( - text = positiveButtonText + GenericBaseSheetContent( + title = title, + bodyContent = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.textSecondaryDark ) + ) + VSpacer.Large() + positiveButtonText?.let { + WrapPrimaryButton( + onClick = { onPositiveClick.invoke() }, + modifier = Modifier.fillMaxWidth(), + enabled = true + ) { + Text( + text = positiveButtonText + ) + } + } + VSpacer.Medium() + negativeButtonText?.let { + WrapSecondaryButton( + onClick = { onNegativeClick.invoke() }, + modifier = Modifier.fillMaxWidth(), + enabled = true + ) { + Text( + text = negativeButtonText + ) + } } } - VSpacer.Medium() - negativeButtonText?.let { - WrapSecondaryButton( - onClick = { onNegativeClick.invoke() }, - modifier = Modifier.fillMaxWidth(), - enabled = true - ) { + ) +} + +@Composable +fun BottomSheetWithOptionsList( + title: String, + message: String, + options: List, +) { + if (options.isNotEmpty()) { + GenericBaseSheetContent( + title = title, + bodyContent = { Text( - text = negativeButtonText + text = message, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.textSecondaryDark + ) ) + VSpacer.Large() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start + ) { + OptionsList( + optionItems = options, + ) + } } + ) + } +} + +@Composable +fun OptionsList( + optionItems: List, +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(SIZE_SMALL.dp) + ) { + items(optionItems) { item -> + OptionListItem( + item = item, + onItemSelected = { + item.onClick() + }, + ) } } } +@Composable +fun OptionListItem( + item: OptionListItemUi, + onItemSelected: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SIZE_SMALL.dp)) + .background(MaterialTheme.colorScheme.backgroundDefault) + .throttledClickable { + onItemSelected.invoke() + } + .padding( + horizontal = SIZE_SMALL.dp, + vertical = SIZE_MEDIUM.dp + ), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = item.text, + style = MaterialTheme.typography.bodyMedium + ) + WrapIcon( + modifier = Modifier.wrapContentWidth(), + iconData = item.icon, + customTint = MaterialTheme.colorScheme.primary + ) + } +} + @ThemeModePreviews @Composable private fun DialogBottomSheetPreview() { @@ -161,4 +266,29 @@ private fun DialogBottomSheetPreview() { negativeButtonText = "Cancel" ) } +} + +@ThemeModePreviews +@Composable +private fun BottomSheetWithOptionsListPreview() { + PreviewTheme { + BottomSheetWithOptionsList( + title = "Title", + message = "Message", + options = listOf( + OptionListItemUi( + text = "Small Name", + onClick = {} + ), + OptionListItemUi( + text = "MediumMediumMediumMedium Name", + onClick = {} + ), + OptionListItemUi( + text = "LargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLargeLarge Name", + onClick = {} + ), + ), + ) + } } \ No newline at end of file diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/extension/ModifierExtensions.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/extension/ModifierExtensions.kt index 92229522..b1b4a95d 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/extension/ModifierExtensions.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/extension/ModifierExtensions.kt @@ -28,8 +28,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -115,4 +126,42 @@ inline fun Modifier.clickableNoRipple( interactionSource = remember { MutableInteractionSource() }) { onClick() } -} \ No newline at end of file +} + +fun Modifier.dashedBorder( + brush: Brush, + shape: Shape, + strokeWidth: Dp = 2.dp, + dashLength: Dp = 4.dp, + gapLength: Dp = 4.dp, + cap: StrokeCap = StrokeCap.Round +) = this.drawWithContent { + val outline = shape.createOutline(size, layoutDirection, density = this) + + val dashedStroke = Stroke( + cap = cap, + width = strokeWidth.toPx(), + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(dashLength.toPx(), gapLength.toPx()) + ) + ) + + // Draw the content + drawContent() + + // Draw the border + drawOutline( + outline = outline, + style = dashedStroke, + brush = brush + ) +} + +fun Modifier.dashedBorder( + color: Color, + shape: Shape, + strokeWidth: Dp = 2.dp, + dashLength: Dp = 4.dp, + gapLength: Dp = 4.dp, + cap: StrokeCap = StrokeCap.Round +) = dashedBorder(brush = SolidColor(color), shape, strokeWidth, dashLength, gapLength, cap) \ No newline at end of file diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/RouterContract.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/RouterContract.kt index 23bfd8a7..a46f5676 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/RouterContract.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/RouterContract.kt @@ -92,7 +92,6 @@ sealed class IssuanceScreens { name = "ISSUANCE_DOCUMENT_DETAILS", parameters = "?detailsType={detailsType}" + "&documentId={documentId}" - + "&documentType={documentType}" ) data object DocumentOffer : Screen( diff --git a/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/helper/DeepLinkHelper.kt b/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/helper/DeepLinkHelper.kt index 3c3d31c6..b44430c5 100644 --- a/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/helper/DeepLinkHelper.kt +++ b/ui-logic/src/main/java/eu/europa/ec/uilogic/navigation/helper/DeepLinkHelper.kt @@ -25,7 +25,6 @@ import androidx.core.os.bundleOf import androidx.navigation.NavController import eu.europa.ec.businesslogic.util.safeLet import eu.europa.ec.corelogic.util.CoreActions -import eu.europa.ec.eudi.wallet.EudiWallet import eu.europa.ec.uilogic.BuildConfig import eu.europa.ec.uilogic.container.EudiComponentActivity import eu.europa.ec.uilogic.extension.openUrl @@ -117,8 +116,11 @@ fun handleDeepLinkAction( } DeepLinkType.ISSUANCE -> { - EudiWallet.resumeOpenId4VciWithAuthorization(action.link) - notify(navController.context, CoreActions.VCI_RESUME_ACTION) + notify( + navController.context, + CoreActions.VCI_RESUME_ACTION, + bundleOf(Pair("uri", action.link.toString())) + ) return }