diff --git a/.gitignore b/.gitignore index 8826dd40..7f06374a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ Multibranch.Jenkinsfile ci ci-overrides.properties nexus-init.gradle.kts -doc +documentation-internal android/src/androidTest diff --git a/ReleaseNotes.md b/ReleaseNotes.md index eb3c8e2b..88d4b4a8 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,3 +1,7 @@ +# Release 1.10.0 +- Began implementation for support of private insurances +- Bugfixes + # Release 1.9.0 - Small refactorings - Bugfixes diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugPKV.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugPKV.kt index 649793fd..302a7994 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugPKV.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugPKV.kt @@ -20,32 +20,36 @@ package de.gematik.ti.erp.app.debug.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import kotlinx.coroutines.launch import org.kodein.di.compose.rememberViewModel @Composable fun DebugScreenPKV(onBack: () -> Unit) { val viewModel by rememberViewModel() val listState = rememberLazyListState() - val context = LocalContext.current AnimatedElevationScaffold( navigationMode = NavigationBarMode.Back, @@ -54,7 +58,7 @@ fun DebugScreenPKV(onBack: () -> Unit) { onBack = onBack ) { innerPadding -> var invoiceBundle by remember { mutableStateOf("") } - var attachement by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() LazyColumn( state = listState, @@ -66,6 +70,24 @@ fun DebugScreenPKV(onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), contentPadding = PaddingValues(PaddingDefaults.Medium) ) { + item { + DebugCard(title = "Login state") { + val profileHandler = LocalProfileHandler.current + val activeProfile = profileHandler.activeProfile + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { + scope.launch { + profileHandler.switchProfileToPKV(activeProfile) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Set User with ${activeProfile.name} as PKV", textAlign = TextAlign.Center) + } + } + } + } item { DebugCard( title = "Invoice Bundle" @@ -79,18 +101,9 @@ fun DebugScreenPKV(onBack: () -> Unit) { }, maxLines = 1 ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = attachement, - label = { Text("Attachement") }, - onValueChange = { - attachement = it - }, - maxLines = 1 - ) DebugLoadingButton( - onClick = { viewModel.shareInvoicePDF(context, invoiceBundle, attachement) }, - text = "Share" + onClick = { viewModel.saveInvoice(invoiceBundle) }, + text = "Save Invoice" ) } } diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt index b79f6edf..8e3f71bf 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card -import androidx.compose.material.Checkbox import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -80,7 +79,6 @@ import androidx.navigation.compose.rememberNavController import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.debug.data.Environment -import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AlertDialog @@ -172,54 +170,6 @@ fun EditablePathComponentSetButton( ) } -@Composable -fun TextWithResetButtonComponent( - modifier: Modifier = Modifier, - label: String, - onClick: () -> Unit, - active: Boolean -) { - val color = if (active) Color.Green else Color.Red - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { - Text( - text = label, - modifier = Modifier - .weight(1f) - .padding(end = PaddingDefaults.Medium) - ) - val text = if (active) "UNSET" else "RESET" - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors(backgroundColor = color), - enabled = !active - ) { - Text(text = text) - } - } -} - -@Composable -fun EditablePathComponentCheckable( - modifier: Modifier = Modifier, - label: String, - textFieldValue: String, - checked: Boolean, - onValueChange: (String, Boolean) -> Unit -) { - EditablePathComponentWithControl( - modifier = modifier, - label = label, - textFieldValue = textFieldValue, - onValueChange = onValueChange, - content = { onChange -> - Checkbox( - checked = checked, - onCheckedChange = onChange - ) - } - ) -} - @Composable fun EditablePathComponentWithControl( modifier: Modifier, @@ -257,6 +207,7 @@ fun DebugScreen( endpointHelper = instance(), cardWallUseCase = instance(), prescriptionUseCase = instance(), + invoiceRepository = instance(), vauRepository = instance(), idpRepository = instance(), idpUseCase = instance(), @@ -451,12 +402,6 @@ fun DebugScreenMain( val modal = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() - val featuresState by produceState(initialValue = mutableMapOf()) { - viewModel.featuresState().collect { - value = it - } - } - ModalBottomSheetLayout( sheetContent = { EnvironmentSelector( @@ -576,24 +521,6 @@ fun DebugScreenMain( FeatureToggles(viewModel = viewModel) } - item { - DebugCard(title = "Login state") { - val profileHandler = LocalProfileHandler.current - val active = profileHandler.activeProfile - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Button( - onClick = { - scope.launch { - profileHandler.switchProfileToPKV(active) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Set User with ${active.name} as PKV", textAlign = TextAlign.Center) - } - } - } - } item { RotatingLog(viewModel = viewModel) } diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt index 0c4dd548..2ffbc81c 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt @@ -18,7 +18,6 @@ package de.gematik.ti.erp.app.debug.ui -import android.content.Context import android.content.Intent import android.content.pm.PackageManager import androidx.compose.runtime.getValue @@ -38,16 +37,10 @@ import de.gematik.ti.erp.app.debug.data.Environment import de.gematik.ti.erp.app.di.EndpointHelper import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager import de.gematik.ti.erp.app.featuretoggle.Features -import de.gematik.ti.erp.app.fhir.model.extractPKVInvoiceBundle import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.repository.IdpRepository import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.invoice.usecase.PkvHtmlTemplate -import de.gematik.ti.erp.app.invoice.usecase.createPkvHtmlInvoiceTemplate -import de.gematik.ti.erp.app.invoice.usecase.createSharableFileInCache -import de.gematik.ti.erp.app.invoice.usecase.sharePDFFile -import de.gematik.ti.erp.app.invoice.usecase.writePDFAttachment -import de.gematik.ti.erp.app.invoice.usecase.writePdfFromHtml +import de.gematik.ti.erp.app.invoice.repository.InvoiceRepository import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyDirectRedeemUseCase import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier @@ -83,6 +76,7 @@ class DebugSettingsViewModel( private val prescriptionUseCase: PrescriptionUseCase, private val vauRepository: VauRepository, private val idpRepository: IdpRepository, + private val invoiceRepository: InvoiceRepository, private val idpUseCase: IdpUseCase, private val profilesUseCase: ProfilesUseCase, private val featureToggleManager: FeatureToggleManager, @@ -162,6 +156,7 @@ class DebugSettingsViewModel( pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, pharmacyServiceActive = true ) + Environment.RUDEV -> debugSettingsData.copy( eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_RU_DEV, eRezeptActive = true, @@ -170,6 +165,7 @@ class DebugSettingsViewModel( pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, pharmacyServiceActive = true ) + Environment.TR -> debugSettingsData.copy( eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_TR, eRezeptActive = true, @@ -267,55 +263,6 @@ class DebugSettingsViewModel( } } - fun shareInvoicePDF(context: Context, bundle: String, attachement: String) { - viewModelScope.launch { - val html = extractPKVInvoiceBundle( - Json.parseToJsonElement(bundle), - processDispense = { whenHandedOver -> - whenHandedOver.formattedString() - }, - processPharmacyAddress = { line, postalCode, city -> - requireNotNull(line) - requireNotNull(postalCode) - requireNotNull(city) - (line + "$postalCode $city").joinToString() - }, - processPharmacy = { name, address, _, iknr, _, _ -> - PkvHtmlTemplate.createOrganization( - organizationName = requireNotNull(name), - organizationAddress = address, - organizationIKNR = iknr - ) - }, - processInvoice = { totalAdditionalFee, totalBruttoAmount, currency, items -> - PkvHtmlTemplate.createPriceData( - currency = currency, - totalBruttoAmount = totalBruttoAmount, - items = items - ) - }, - save = { pharmacy, invoice, dispense -> - createPkvHtmlInvoiceTemplate( - patient = "TODO", - patientBirthdate = "TODO", - prescriber = "TODO", - prescribedOn = "TODO", - organization = pharmacy, - dispensedOn = dispense, - priceData = invoice - ) - } - ) - - requireNotNull(html) { "HTML string required" } - - val file = createSharableFileInCache(context, "invoices", "invoice") - writePdfFromHtml(context, "Invoice", html, file) - writePDFAttachment(file, Triple("Test123", "text/plain", attachement.toByteArray())) - sharePDFFile(context, file) - } - } - fun features() = featureToggleManager.features fun featuresState() = @@ -406,4 +353,12 @@ class DebugSettingsViewModel( recipientCertificates = certificates ).getOrThrow() } + + fun saveInvoice(invoiceBundle: String) { + viewModelScope.launch { + val profileId = profilesUseCase.activeProfileId().first() + val bundle = Json.parseToJsonElement(invoiceBundle) + invoiceRepository.saveInvoice(profileId, bundle) + } + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt b/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt index ca3c4485..072daeec 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt @@ -56,38 +56,29 @@ import com.google.android.play.core.appupdate.AppUpdateManagerFactory import com.google.android.play.core.appupdate.AppUpdateOptions import com.google.android.play.core.install.model.AppUpdateType.IMMEDIATE import com.google.android.play.core.install.model.UpdateAvailability -import de.gematik.ti.erp.app.cardunlock.ui.UnlockEgkViewModel +import de.gematik.ti.erp.app.analytics.Analytics import de.gematik.ti.erp.app.cardwall.mini.ui.ExternalAuthPrompt import de.gematik.ti.erp.app.cardwall.mini.ui.HealthCardPrompt -import de.gematik.ti.erp.app.cardwall.mini.ui.MiniCardWallViewModel -import de.gematik.ti.erp.app.cardwall.mini.ui.rememberAuthenticator -import de.gematik.ti.erp.app.cardwall.ui.CardWallController import de.gematik.ti.erp.app.cardwall.ui.ExternalAuthenticatorListViewModel import de.gematik.ti.erp.app.core.LocalActivity import de.gematik.ti.erp.app.core.LocalAuthenticator -import de.gematik.ti.erp.app.core.LocalAnalytics import de.gematik.ti.erp.app.core.MainContent import de.gematik.ti.erp.app.di.ApplicationPreferencesTag import de.gematik.ti.erp.app.mainscreen.ui.MainScreen -import de.gematik.ti.erp.app.orderhealthcard.ui.HealthCardOrderViewModel -import de.gematik.ti.erp.app.prescription.detail.ui.PrescriptionDetailsViewModel -import de.gematik.ti.erp.app.prescription.ui.ScanPrescriptionViewModel -import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel -import de.gematik.ti.erp.app.profiles.ui.ProfileViewModel -import de.gematik.ti.erp.app.analytics.Analytics import de.gematik.ti.erp.app.apicheck.usecase.CheckVersionUseCase import de.gematik.ti.erp.app.cardwall.mini.ui.SecureHardwarePrompt +import de.gematik.ti.erp.app.cardwall.mini.ui.rememberAuthenticator import de.gematik.ti.erp.app.core.IntentHandler +import de.gematik.ti.erp.app.core.LocalAnalytics import de.gematik.ti.erp.app.core.LocalIntentHandler import de.gematik.ti.erp.app.mainscreen.ui.rememberMainScreenController -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyOverviewViewModel import de.gematik.ti.erp.app.prescription.detail.ui.SharePrescriptionHandler import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler import de.gematik.ti.erp.app.profiles.ui.rememberProfileHandler +import de.gematik.ti.erp.app.profiles.ui.rememberProfilesController import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationModeAndMethod import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationUseCase import de.gematik.ti.erp.app.userauthentication.ui.UserAuthenticationScreen -import de.gematik.ti.erp.app.userauthentication.ui.UserAuthenticationViewModel import de.gematik.ti.erp.app.utils.compose.DebugOverlay import de.gematik.ti.erp.app.utils.compose.DialogHost import kotlinx.coroutines.flow.Flow @@ -101,7 +92,6 @@ import org.kodein.di.android.closestDI import org.kodein.di.android.retainedSubDI import org.kodein.di.bindProvider import org.kodein.di.bindSingleton -import org.kodein.di.compose.rememberViewModel import org.kodein.di.compose.withDI import org.kodein.di.instance @@ -114,27 +104,7 @@ class MainActivity : AppCompatActivity(), DIAware { if (BuildKonfig.INTERNAL) { fullContainerTreeOnError = true } - - bindProvider { UnlockEgkViewModel(instance(), instance()) } - bindProvider { MiniCardWallViewModel(instance(), instance(), instance(), instance(), instance()) } - bindProvider { CardWallController(instance(), instance(), instance()) } bindProvider { ExternalAuthenticatorListViewModel(instance(), instance()) } - bindProvider { HealthCardOrderViewModel(instance()) } - bindProvider { PrescriptionDetailsViewModel(instance(), instance()) } - bindProvider { - ScanPrescriptionViewModel( - prescriptionUseCase = instance(), - profilesUseCase = instance(), - scanner = instance(), - processor = instance(), - validator = instance(), - dispatchers = instance() - ) - } - bindProvider { ProfileViewModel(instance()) } - bindProvider { ProfileSettingsViewModel(instance(), instance()) } - bindProvider { UserAuthenticationViewModel(instance()) } - bindProvider { PharmacyOverviewViewModel(instance()) } bindProvider { CheckVersionUseCase(instance(), instance()) } if (BuildConfig.DEBUG && BuildKonfig.INTERNAL) { @@ -267,7 +237,7 @@ class MainActivity : AppCompatActivity(), DIAware { ) val mainScreenController = rememberMainScreenController() - val profileSettingsViewModel by rememberViewModel() + val profilesController = rememberProfilesController() CompositionLocalProvider( LocalProfileHandler provides rememberProfileHandler() @@ -276,7 +246,7 @@ class MainActivity : AppCompatActivity(), DIAware { navController = navController, settingsController = settingsController, mainScreenController = mainScreenController, - profileSettingsViewModel = profileSettingsViewModel + profilesController = profilesController ) SharePrescriptionHandler(authenticationModeAndMethod) diff --git a/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt b/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt index 71b59aed..f4995df8 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt @@ -363,6 +363,7 @@ object TestTag { val ProfileScreen by tagName() val ProfileScreenContent by tagName() val InvoicesScreen by tagName() + val InvoicesDetailScreen by tagName() val InvoicesScreenContent by tagName() val OpenTokensScreenButton by tagName() val InsuranceId by tagName() diff --git a/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt b/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt index a9847f36..098f7c3a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt @@ -31,12 +31,11 @@ import de.gematik.ti.erp.app.core.LocalAnalytics import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import io.github.aakira.napier.Napier -import java.security.MessageDigest private const val PrefsName = "analyticsAllowed" // `gemSpec_eRp_FdV A_20187` -class Analytics constructor( +class Analytics( private val context: Context, private val prefs: SharedPreferences ) { @@ -44,23 +43,12 @@ class Analytics constructor( val analyticsAllowed: StateFlow get() = _analyticsAllowed - // TODO remove in future versions - private val piwikPrefsName = "pro.piwik.sdk_" + - MessageDigest.getInstance("MD5").digest("Tracker".toByteArray()) - .joinToString(separator = "") { eachByte -> "%02X".format(eachByte) } - init { Napier.d("Init Analytics") Contentsquare.forgetMe() - // TODO remove in future versions - val piwikOptOut = !context.getSharedPreferences( - piwikPrefsName, - Context.MODE_PRIVATE - ).getBoolean("tracker.optout", true) - - _analyticsAllowed.value = prefs.getBoolean(PrefsName, !piwikOptOut) + _analyticsAllowed.value = prefs.getBoolean(PrefsName, false) if (_analyticsAllowed.value) { allowTracking() } else { diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt index 97036258..8ff9d841 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt @@ -68,7 +68,6 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.SpacerTiny import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge -import org.kodein.di.compose.rememberViewModel const val SECRET_MIN_LENGTH = 6 const val SECRET_MAX_LENGTH = 8 @@ -85,7 +84,7 @@ fun UnlockEgKScreen( onCancel: () -> Unit, onClickLearnMore: () -> Unit ) { - val viewModel by rememberViewModel() + val unlockEgkController = rememberUnlockEgkController() var unlockMethod by rememberSaveable { mutableStateOf(unlockMethod) } val unlockNavController = rememberNavController() @@ -175,7 +174,7 @@ fun UnlockEgKScreen( NavigationAnimation { UnlockScreen( unlockMethod = unlockMethod, - viewModel = viewModel, + unlockEgkController = unlockEgkController, cardAccessNumber = cardAccessNumber, personalUnblockingKey = personalUnblockingKey, oldSecret = oldSecret, @@ -468,7 +467,7 @@ private fun NewSecretScreen( ) { val secretRange = SECRET_MIN_LENGTH..SECRET_MAX_LENGTH var repeatedNewSecret by remember { mutableStateOf("") } - val isConsistent by remember { + val isConsistent by remember(newSecret, repeatedNewSecret) { derivedStateOf { repeatedNewSecret.isNotBlank() && newSecret == repeatedNewSecret } @@ -568,7 +567,7 @@ private fun NewSecretScreen( @Composable private fun UnlockScreen( unlockMethod: UnlockMethod, - viewModel: UnlockEgkViewModel, + unlockEgkController: UnlockEgkController, cardAccessNumber: String, personalUnblockingKey: String, oldSecret: String, @@ -588,7 +587,7 @@ private fun UnlockScreen( UnlockEgkDialog( unlockMethod = unlockMethod, dialogState = dialogState, - viewModel = viewModel, + unlockEgkController = unlockEgkController, cardAccessNumber = cardAccessNumber, personalUnblockingKey = personalUnblockingKey, troubleShootingEnabled = true, diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkController.kt similarity index 77% rename from android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkController.kt index 5ac4c7e4..f8c5a491 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkController.kt @@ -19,20 +19,24 @@ package de.gematik.ti.erp.app.cardunlock.ui import android.nfc.Tag +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkState import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkUseCase import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard -import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.card.model.command.UnlockMethod import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import org.kodein.di.compose.rememberInstance -class UnlockEgkViewModel( +@Stable +class UnlockEgkController( private val unlockEgkUseCase: UnlockEgkUseCase, private val dispatchers: DispatchProvider -) : ViewModel() { +) { fun unlockEgk( unlockMethod: UnlockMethod, can: String, @@ -52,3 +56,15 @@ class UnlockEgkViewModel( ).flowOn(dispatchers.IO) } } + +@Composable +fun rememberUnlockEgkController(): UnlockEgkController { + val unlockEgkUseCase by rememberInstance() + val dispatchers by rememberInstance() + return remember { + UnlockEgkController( + unlockEgkUseCase, + dispatchers + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt index 52021d09..f5bb8914 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt @@ -111,7 +111,7 @@ fun rememberUnlockEgkDialogState(): UnlockEgkDialogState { fun UnlockEgkDialog( unlockMethod: UnlockMethod, dialogState: UnlockEgkDialogState, - viewModel: UnlockEgkViewModel, + unlockEgkController: UnlockEgkController, cardAccessNumber: String, personalUnblockingKey: String, oldSecret: String, @@ -140,7 +140,7 @@ fun UnlockEgkDialog( if (it.value) { showCardCommunicationDialog = true emitAll( - viewModel.unlockEgk( + unlockEgkController.unlockEgk( unlockMethod = unlockMethod, can = cardAccessNumber, puk = personalUnblockingKey, @@ -173,7 +173,7 @@ fun UnlockEgkDialog( } } emitAll( - viewModel.unlockEgk( + unlockEgkController.unlockEgk( unlockMethod = unlockMethod, can = cardAccessNumber, puk = personalUnblockingKey, diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt index 2e6ef821..ee6203a7 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt @@ -55,7 +55,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import org.kodein.di.compose.rememberViewModel import java.net.URI class NoneEnrolledException : IllegalStateException() @@ -237,7 +236,7 @@ fun PromptScaffold( @Composable fun rememberAuthenticator(intentHandler: IntentHandler): Authenticator { - val bridge by rememberViewModel() + val bridge = rememberMiniCardWallController() val promptSE = rememberSecureHardwarePromptAuthenticator(bridge) val promptHC = rememberHealthCardPromptAuthenticator(bridge) val promptEX = rememberExternalPromptAuthenticator(bridge, intentHandler) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallController.kt similarity index 85% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallController.kt index 1afe7478..72ddc7d4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallController.kt @@ -19,12 +19,14 @@ package de.gematik.ti.erp.app.cardwall.mini.ui import android.nfc.Tag +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase import de.gematik.ti.erp.app.cardwall.usecase.MiniCardWallUseCase -import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.idp.api.models.AuthenticationId import de.gematik.ti.erp.app.idp.api.models.IdpScope import de.gematik.ti.erp.app.idp.model.IdpData @@ -36,21 +38,23 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext +import org.kodein.di.compose.rememberInstance import java.net.URI /** - * The [MiniCardWallViewModel] is used for refreshing tokens of several authentication methods. + * The [MiniCardWallController] is used for refreshing tokens of several authentication methods. * While the actual mini card wall is just the prompt for authentication with health card or external authentication, * the biometric/alternate authentication uses the prompt provided by the system. */ -class MiniCardWallViewModel( +@Stable +class MiniCardWallController( private val useCase: MiniCardWallUseCase, private val authenticationUseCase: AuthenticationUseCase, private val idpUseCase: IdpUseCase, private val idpRepository: IdpRepository, private val dispatchers: DispatchProvider -) : ViewModel(), AuthenticationBridge { +) : AuthenticationBridge { private fun PromptAuthenticator.AuthScope.toIdpScope() = when (this) { PromptAuthenticator.AuthScope.Prescriptions -> IdpScope.Default @@ -139,3 +143,22 @@ class MiniCardWallViewModel( } } } + +@Composable +fun rememberMiniCardWallController(): MiniCardWallController { + val useCase by rememberInstance() + val authenticationUseCase by rememberInstance() + val idpUseCase by rememberInstance() + val idpRepository by rememberInstance() + val dispatchers by rememberInstance() + + return remember { + MiniCardWallController( + useCase, + authenticationUseCase, + idpUseCase, + idpRepository, + dispatchers + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt b/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt index a3bc31e8..04fb67b3 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt @@ -29,7 +29,6 @@ private val Context.dataStore by preferencesDataStore("featureToggles") enum class Features(val featureName: String) { REDEEM_WITHOUT_TI("RedeemWithoutTI"), - PKV("PKV") } class FeatureToggleManager(val context: Context) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/invoice/usecase/CreatePdf.kt b/android/src/main/java/de/gematik/ti/erp/app/invoice/usecase/CreatePdf.kt index dfe793a8..b333778b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/invoice/usecase/CreatePdf.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/invoice/usecase/CreatePdf.kt @@ -19,28 +19,28 @@ package de.gematik.ti.erp.app.invoice.usecase import android.content.Context -import android.content.Intent import android.print.PdfPrint import android.print.PrintAttributes import android.print.PrintDocumentAdapter import android.util.Base64 import android.webkit.WebView import android.webkit.WebViewClient +import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDDocumentNameDictionary import com.tom_roush.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode import com.tom_roush.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification import com.tom_roush.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile +import de.gematik.ti.erp.app.BuildConfig import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import java.util.GregorianCalendar -import java.util.UUID +import java.util.* import kotlin.coroutines.suspendCoroutine -private const val FileProviderAuthority = "de.gematik.ti.erp.app.fileprovider" +private const val FileProviderAuthority = "${BuildConfig.APPLICATION_ID}.fileprovider" private const val PDFDensity = 600 private const val PDFMargin = 24 @@ -64,17 +64,15 @@ fun createSharableFileInCache(context: Context, path: String, filePrefix: String return newFile } -fun sharePDFFile(context: Context, file: File) { - val contentUri = FileProvider.getUriForFile(context, FileProviderAuthority, file) +fun sharePDFFile(context: Context, file: File, subject: String) { + val uri = FileProvider.getUriForFile(context, FileProviderAuthority, file) - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, "") - putExtra(Intent.EXTRA_STREAM, contentUri) - type = "application/pdf" - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } - context.startActivity(Intent.createChooser(shareIntent, null)) + ShareCompat.IntentBuilder(context) + .setType("application/pdf") + .setSubject(subject) + .addStream(uri) + .setChooserTitle(subject) + .startChooser() } suspend fun writePdfFromHtml(context: Context, title: String, html: String, out: File) = @@ -112,7 +110,7 @@ suspend fun writePDF(adapter: PrintDocumentAdapter, out: File) { pdfPrint.print(adapter, out) } -fun writePDFAttachment(out: File, vararg attachments: Triple) { +fun writePDFAttachments(out: File, attachments: List>) { val doc = PDDocument.load(out) val efTree = PDEmbeddedFilesNameTreeNode() diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt index bd11aeed..cb60645d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt @@ -60,7 +60,7 @@ import de.gematik.ti.erp.app.profiles.ui.AvatarPicker import de.gematik.ti.erp.app.profiles.ui.ColorPicker import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler import de.gematik.ti.erp.app.profiles.ui.ProfileImage -import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel +import de.gematik.ti.erp.app.profiles.ui.ProfilesController import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.settings.ui.SettingsController import de.gematik.ti.erp.app.theme.AppTheme @@ -90,7 +90,7 @@ sealed class MainScreenBottomSheetContentState { @Composable fun MainScreenBottomSheetContentState( settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesController: ProfilesController, infoContentState: MainScreenBottomSheetContentState?, mainNavController: NavController, profileToRename: ProfilesUseCaseData.Profile, @@ -132,7 +132,9 @@ fun MainScreenBottomSheetContentState( EditProfileAvatar( profile = profileHandler.activeProfile, clearPersonalizedImage = { - profileSettingsViewModel.clearPersonalizedImage(profileHandler.activeProfile.id) + scope.launch { + profilesController.clearPersonalizedImage(profileHandler.activeProfile.id) + } }, onPickPersonalizedImage = { mainNavController.navigate( @@ -142,16 +144,19 @@ fun MainScreenBottomSheetContentState( ) }, onSelectAvatar = { avatar -> - profileSettingsViewModel.saveAvatarFigure(profileHandler.activeProfile.id, avatar) + scope.launch { + profilesController.saveAvatarFigure(profileHandler.activeProfile.id, avatar) + } }, onSelectProfileColor = { color -> - profileSettingsViewModel.updateProfileColor(profileHandler.activeProfile, color) + scope.launch { + profilesController.updateProfileColor(profileHandler.activeProfile, color) + } } ) is MainScreenBottomSheetContentState.EditOrAddProfileName -> ProfileSheetContent( - settingsController = settingsController, - profileSettingsViewModel = profileSettingsViewModel, + profilesController = profilesController, addProfile = it.addProfile, profileToEdit = if (!it.addProfile) { profileToRename @@ -184,24 +189,25 @@ fun MainScreenBottomSheetContentState( @OptIn(ExperimentalComposeUiApi::class) @Composable fun ProfileSheetContent( - settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesController: ProfilesController, profileToEdit: ProfilesUseCaseData.Profile?, addProfile: Boolean = false, onCancel: () -> Unit ) { val keyboardController = LocalSoftwareKeyboardController.current val scope = rememberCoroutineScope() - val profilesState by settingsController.profilesState + val profilesState by profilesController.profilesState var textValue by remember { mutableStateOf(profileToEdit?.name ?: "") } var duplicated by remember { mutableStateOf(false) } val onEdit = { if (!addProfile) { - profileToEdit?.let { profileSettingsViewModel.updateProfileName(it.id, textValue) } + profileToEdit?.let { + scope.launch { profilesController.updateProfileName(it.id, textValue) } + } } else { scope.launch { - settingsController.addProfile(textValue) + profilesController.addProfile(textValue) } } onCancel() diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt index f63113f4..41a25a12 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt @@ -100,18 +100,17 @@ import de.gematik.ti.erp.app.prescription.ui.ArchiveScreen import de.gematik.ti.erp.app.prescription.ui.MlKitInformationScreen import de.gematik.ti.erp.app.prescription.ui.MlKitIntroScreen import de.gematik.ti.erp.app.prescription.ui.PrescriptionScreen -import de.gematik.ti.erp.app.prescription.ui.ScanPrescriptionViewModel import de.gematik.ti.erp.app.prescription.ui.ScanScreen import de.gematik.ti.erp.app.prescription.ui.rememberPrescriptionState -import de.gematik.ti.erp.app.profiles.ui.DefaultProfile +import de.gematik.ti.erp.app.prescription.ui.rememberScanPrescriptionController import de.gematik.ti.erp.app.profiles.ui.EditProfileScreen import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler import de.gematik.ti.erp.app.profiles.ui.ProfileImageCropper -import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel +import de.gematik.ti.erp.app.profiles.ui.ProfilesController +import de.gematik.ti.erp.app.profiles.ui.ProfilesStateData import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.redeem.ui.RedeemNavigation import de.gematik.ti.erp.app.settings.ui.AllowAnalyticsScreen -import de.gematik.ti.erp.app.settings.ui.AllowBiometryScreen import de.gematik.ti.erp.app.settings.ui.PharmacyLicenseScreen import de.gematik.ti.erp.app.settings.ui.SecureAppWithPassword import de.gematik.ti.erp.app.settings.ui.SettingsController @@ -133,7 +132,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import org.kodein.di.compose.rememberViewModel @Suppress("LongMethod") @Composable @@ -141,7 +139,7 @@ fun MainScreen( navController: NavHostController, mainScreenController: MainScreenController, settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel + profilesController: ProfilesController ) { val startDestination = determineStartDestination(settingsController) @@ -157,15 +155,6 @@ fun MainScreen( settingsController = settingsController ) } - composable(OnboardingNavigationScreens.Biometry.route) { - NavigationAnimation(mode = navigationMode) { - AllowBiometryScreen( - onBack = { navController.popBackStack() }, - onNext = { navController.popBackStack() }, - onSecureMethodChange = { } - ) - } - } composable(MainNavigationScreens.DataProtection.route) { NavigationAnimation(mode = navigationMode) { WebViewScreen( @@ -185,15 +174,15 @@ fun MainScreen( ) } composable(MainNavigationScreens.Camera.route) { - val scanViewModel by rememberViewModel() - ScanScreen(mainNavController = navController, scanViewModel = scanViewModel) + val scanPrescriptionController = rememberScanPrescriptionController() + ScanScreen(mainNavController = navController, scanPrescriptionController = scanPrescriptionController) } composable(MainNavigationScreens.Prescriptions.route) { MainScreenWithScaffold( mainNavController = navController, mainScreenController = mainScreenController, settingsController = settingsController, - profileSettingsViewModel = profileSettingsViewModel + profilesController = profilesController ) } @@ -306,8 +295,7 @@ fun MainScreen( remember { navController.currentBackStackEntry?.arguments?.getString("profileId")!! } EditProfileScreen( profileId, - settingsController, - profileSettingsViewModel, + profilesController, onBack = { navController.popBackStack() }, mainNavController = navController ) @@ -385,17 +373,16 @@ fun MainScreen( ) { val profileId = remember { it.arguments!!.getString("profileId")!! } val scope = rememberCoroutineScope() - val profilesState by settingsController.profilesState + val profilesState by profilesController.profilesState profilesState.profileById(profileId)?.let { profile -> EditProfileScreen( profilesState, profile, - settingsController, - profileSettingsViewModel, + profilesController, onRemoveProfile = { scope.launch { - settingsController.removeProfile(profile, it) + profilesController.removeProfile(profile, it) } navController.popBackStack() }, @@ -410,10 +397,12 @@ fun MainScreen( MainNavigationScreens.ProfileImageCropper.arguments ) { val profileId = remember { it.arguments!!.getString("profileId")!! } - + val scope = rememberCoroutineScope() ProfileImageCropper( onSaveCroppedImage = { - profileSettingsViewModel.savePersonalizedProfileImage(profileId, it) + scope.launch { + profilesController.savePersonalizedProfileImage(profileId, it) + } navController.popBackStack() }, onBack = { @@ -478,7 +467,7 @@ private fun MainScreenWithScaffold( mainNavController: NavController, mainScreenController: MainScreenController, settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel + profilesController: ProfilesController ) { val context = LocalContext.current val bottomNavController = rememberNavController() @@ -546,7 +535,7 @@ private fun MainScreenWithScaffold( } var profileToRename by remember { - mutableStateOf(DefaultProfile) + mutableStateOf(ProfilesStateData.defaultProfile) } val toolTipBounds = remember { @@ -572,7 +561,7 @@ private fun MainScreenWithScaffold( sheetContent = { MainScreenBottomSheetContentState( settingsController = settingsController, - profileSettingsViewModel = profileSettingsViewModel, + profilesController = profilesController, infoContentState = mainScreenBottomSheetContentState, mainNavController = mainNavController, profileToRename = profileToRename, diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt index 7e4aab1e..ac149b2e 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt @@ -33,12 +33,10 @@ data class TaskIds(val ids: List) : Parcelable, List by ids object MainNavigationScreens { object Onboarding : Route("Onboarding") - object Biometry : Route("Biometry") object Settings : Route("Settings") object Camera : Route("Camera") object Prescriptions : Route("Prescriptions") object Archive : Route("Archive") - object PrescriptionDetail : Route( "PrescriptionDetail", @@ -75,8 +73,6 @@ object MainNavigationScreens { object InsecureDeviceScreen : Route("InsecureDeviceScreen") object MlKitIntroScreen : Route("MlKitIntroScreen") object MlKitInformationScreen : Route("MlKitInformationScreen") - - object DataTermsUpdateScreen : Route("DataTermsUpdateScreen") object DataProtection : Route("DataProtection") object IntegrityNotOkScreen : Route("IntegrityInfoScreen") object EditProfile : diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt index 5a57898a..7e361aeb 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt @@ -59,7 +59,6 @@ import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -83,7 +82,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.TestTag -import de.gematik.ti.erp.app.orderhealthcard.ui.model.HealthCardOrderViewModelData import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData import de.gematik.ti.erp.app.settings.ui.openMailClient import de.gematik.ti.erp.app.theme.AppTheme @@ -95,18 +93,13 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge import de.gematik.ti.erp.app.utils.compose.navigationModeState -import org.kodein.di.compose.rememberViewModel @Composable fun HealthCardContactOrderScreen( onBack: () -> Unit ) { - val healthCardOrderViewModel by rememberViewModel() - val state by produceState(healthCardOrderViewModel.defaultState) { - healthCardOrderViewModel.screenState().collect { - value = it - } - } + val healthCardOrderState = rememberHealthCardOrderState() + val state by healthCardOrderState.state val navController = rememberNavController() @@ -131,15 +124,33 @@ fun HealthCardContactOrderScreen( ) { HealthCardOrder( listState = listState, - state = state, + healthCardOrderState = state, onSelectCompany = { - healthCardOrderViewModel.onSelectInsuranceCompany(it) - if (it.hasContactInfoForHealthCardAndPin() && it.hasContactInfoForPin()) { - navController.navigate(HealthCardOrderNavigationScreens.SelectOrderOption.path()) - } else { - navController.navigate( - HealthCardOrderNavigationScreens.HealthCardOrderContact.path() - ) + healthCardOrderState.onSelectInsuranceCompany(it) + when (true) { + (it.hasContactInfoForHealthCardAndPin() && it.hasContactInfoForPin()) -> + navController.navigate(HealthCardOrderNavigationScreens.SelectOrderOption.path()) + it.hasContactInfoForHealthCardAndPin() -> { + healthCardOrderState.onSelectContactOption( + HealthCardOrderStateData.ContactInsuranceOption.WithHealthCardAndPin + ) + navController.navigate( + HealthCardOrderNavigationScreens.HealthCardOrderContact.path() + ) + } + it.hasContactInfoForPin() -> { + healthCardOrderState.onSelectContactOption( + HealthCardOrderStateData.ContactInsuranceOption.PinOnly + ) + navController.navigate( + HealthCardOrderNavigationScreens.HealthCardOrderContact.path() + ) + } + else -> { + navController.navigate( + HealthCardOrderNavigationScreens.HealthCardOrderContact.path() + ) + } } } ) @@ -166,7 +177,7 @@ fun HealthCardContactOrderScreen( SelectOrderOption( listState = listState, onSelectOption = { - healthCardOrderViewModel.onSelectContactOption(it) + healthCardOrderState.onSelectContactOption(it) navController.navigate(HealthCardOrderNavigationScreens.HealthCardOrderContact.path()) } ) @@ -225,7 +236,7 @@ private fun HealthInsuranceCompanySelectable( @Composable private fun HealthCardOrder( listState: LazyListState = rememberLazyListState(), - state: HealthCardOrderViewModelData.State, + healthCardOrderState: HealthCardOrderStateData.HealthCardOrderState, onSelectCompany: (HealthCardOrderUseCaseData.HealthInsuranceCompany) -> Unit ) { var searchName by remember { @@ -247,7 +258,7 @@ private fun HealthCardOrder( SpacerMedium() } items( - state.companies.filter { + healthCardOrderState.companies.filter { it.name.contains(searchName, true) } ) { @@ -305,7 +316,7 @@ private fun InsuranceCompanySearchField( @Composable private fun SelectOrderOption( listState: LazyListState = rememberLazyListState(), - onSelectOption: (HealthCardOrderViewModelData.ContactInsuranceOption) -> Unit + onSelectOption: (HealthCardOrderStateData.ContactInsuranceOption) -> Unit ) { LazyColumn( modifier = Modifier @@ -343,7 +354,7 @@ private fun SelectOrderOption( Option( testTag = TestTag.Settings.ContactInsuranceCompany.OrderPinButton, name = stringResource(R.string.cdw_health_insurance_contact_pin_only), - onSelect = { onSelectOption(HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly) } + onSelect = { onSelectOption(HealthCardOrderStateData.ContactInsuranceOption.PinOnly) } ) SpacerMedium() } @@ -351,7 +362,7 @@ private fun SelectOrderOption( Option( testTag = TestTag.Settings.ContactInsuranceCompany.OrderEgkAndPinButton, name = stringResource(R.string.cdw_health_insurance_contact_healthcard_pin), - onSelect = { onSelectOption(HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin) } + onSelect = { onSelectOption(HealthCardOrderStateData.ContactInsuranceOption.WithHealthCardAndPin) } ) } } @@ -384,10 +395,10 @@ private fun Option( @Composable private fun ContactInsurance( listState: LazyListState = rememberLazyListState(), - state: HealthCardOrderViewModelData.State + healthCardOrderState: HealthCardOrderStateData.HealthCardOrderState ) { - state.selectedCompany?.let { - if (state.selectedCompany.noContactInformation()) { + healthCardOrderState.selectedCompany?.let { + if (healthCardOrderState.selectedCompany.noContactInformation()) { NoContactInformation() } else { LazyColumn( @@ -401,7 +412,7 @@ private fun ContactInsurance( ) { item { val header = stringResource(R.string.order_health_card_contact_header) - val info = if (state.selectedCompany.singleContactInformation()) { + val info = if (healthCardOrderState.selectedCompany.singleContactInformation()) { stringResource(R.string.order_health_card_contact_info_single) } else { stringResource(R.string.order_health_card_contact_info) @@ -422,15 +433,15 @@ private fun ContactInsurance( SpacerXXLarge() } - when (state.selectedOption) { - HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin -> + when (healthCardOrderState.selectedOption) { + HealthCardOrderStateData.ContactInsuranceOption.WithHealthCardAndPin -> item { ContactMethodRow( phone = it.healthCardAndPinPhone, url = it.healthCardAndPinUrl, mail = it.healthCardAndPinMail, company = it, - option = state.selectedOption + option = healthCardOrderState.selectedOption ) } @@ -439,13 +450,9 @@ private fun ContactInsurance( ContactMethodRow( phone = null, url = it.pinUrl, - mail = if (it.hasMailContentForPin()) { - it.healthCardAndPinMail - } else { - null - }, + mail = it.healthCardAndPinMail, company = it, - option = state.selectedOption + option = healthCardOrderState.selectedOption ) } } @@ -488,7 +495,7 @@ private fun ContactMethodRow( url: String?, mail: String?, company: HealthCardOrderUseCaseData.HealthInsuranceCompany, - option: HealthCardOrderViewModelData.ContactInsuranceOption + option: HealthCardOrderStateData.ContactInsuranceOption ) { val uriHandler = LocalUriHandler.current @@ -531,7 +538,7 @@ private fun ContactMethodRow( icon = Icons.Filled.MailOutline, onClick = { when { - option == HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin && + option == HealthCardOrderStateData.ContactInsuranceOption.WithHealthCardAndPin && company.hasMailContentForCardAndPin() -> openMailClient( context = context, address = mail, @@ -539,7 +546,7 @@ private fun ContactMethodRow( body = company.bodyCardAndPinMail!! ) - option == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly && + option == HealthCardOrderStateData.ContactInsuranceOption.PinOnly && company.hasMailContentForPin() -> openMailClient( context = context, address = mail, diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderState.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderState.kt new file mode 100644 index 00000000..f85dbe70 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderState.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.orderhealthcard.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import de.gematik.ti.erp.app.Route +import de.gematik.ti.erp.app.orderhealthcard.usecase.HealthCardOrderUseCase +import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import org.kodein.di.compose.rememberInstance + +object HealthCardOrderNavigationScreens { + object HealthCardOrder : Route("HealthCardOrder") + object SelectOrderOption : Route("SelectOrderOption") + object HealthCardOrderContact : Route("HealthCardOrderContact") +} + +class HealthCardOrderState( + healthCardOrderUseCase: HealthCardOrderUseCase +) { + + private var selectedCompanyFlow: MutableStateFlow = + MutableStateFlow(null) + + private var selectedOptionFlow = MutableStateFlow(HealthCardOrderStateData.ContactInsuranceOption.NotChosen) + + private var healthCardOrderStateFlow = combine( + healthCardOrderUseCase.healthInsuranceOrderContacts, + selectedCompanyFlow, + selectedOptionFlow + ) { + companies, company, option -> + HealthCardOrderStateData.HealthCardOrderState(companies, company, option) + } + + val state + @Composable + get() = healthCardOrderStateFlow.collectAsState(HealthCardOrderStateData.defaultHealthCardOrderState) + + fun onSelectInsuranceCompany(company: HealthCardOrderUseCaseData.HealthInsuranceCompany) { + selectedCompanyFlow.value = company + } + + fun onSelectContactOption(option: HealthCardOrderStateData.ContactInsuranceOption) { + selectedOptionFlow.value = option + } +} + +@Composable +fun rememberHealthCardOrderState(): HealthCardOrderState { + val healthCardOrderUseCase by rememberInstance() + return remember { + HealthCardOrderState( + healthCardOrderUseCase + ) + } +} + +object HealthCardOrderStateData { + @Immutable + data class HealthCardOrderState( + val companies: List, + val selectedCompany: HealthCardOrderUseCaseData.HealthInsuranceCompany?, + val selectedOption: ContactInsuranceOption + ) + + val defaultHealthCardOrderState = HealthCardOrderState( + companies = emptyList(), + selectedCompany = null, + selectedOption = ContactInsuranceOption.NotChosen + ) + + enum class ContactInsuranceOption { + WithHealthCardAndPin, PinOnly, NotChosen + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt deleted file mode 100644 index 94afe307..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2023 gematik GmbH - * - * 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 de.gematik.ti.erp.app.orderhealthcard.ui - -import de.gematik.ti.erp.app.Route -import androidx.lifecycle.ViewModel -import de.gematik.ti.erp.app.orderhealthcard.ui.model.HealthCardOrderViewModelData -import de.gematik.ti.erp.app.orderhealthcard.usecase.HealthCardOrderUseCase -import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine - -object HealthCardOrderNavigationScreens { - object HealthCardOrder : Route("HealthCardOrder") - object SelectOrderOption : Route("SelectOrderOption") - object HealthCardOrderContact : Route("HealthCardOrderContact") -} - -class HealthCardOrderViewModel( - private val healthCardOrderUseCase: HealthCardOrderUseCase -) : ViewModel() { - val defaultState = HealthCardOrderViewModelData.State( - companies = emptyList(), - selectedCompany = null, - selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin - ) - - private val state = MutableStateFlow(defaultState) - - fun screenState(): Flow = - state.combine(healthCardOrderUseCase.healthInsuranceOrderContacts()) { state, companies -> - state.copy(companies = companies) - } - - fun onSelectInsuranceCompany(company: HealthCardOrderUseCaseData.HealthInsuranceCompany) { - state.value = state.value.copy( - selectedCompany = company, - selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin - ) - } - - fun onSelectContactOption(option: HealthCardOrderViewModelData.ContactInsuranceOption) { - state.value = state.value.copy(selectedOption = option) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt index 0c073f12..cd87a0a1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt @@ -21,6 +21,7 @@ package de.gematik.ti.erp.app.orderhealthcard.usecase import android.content.Context import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -35,9 +36,10 @@ class HealthCardOrderUseCase( ).sortedBy { it.name.lowercase() } } - fun healthInsuranceOrderContacts() = flow { - emit(companies) - } + val healthInsuranceOrderContacts: Flow> + get() = flow { + emit(companies) + } } fun loadHealthInsuranceContactsFromJSON( diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt index c440be10..e9e391cf 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt @@ -62,7 +62,10 @@ object HealthCardOrderUseCaseData { ) fun hasContactInfoForPin() = - !pinUrl.isNullOrEmpty() || (!bodyPinMail.isNullOrEmpty() && !subjectPinMail.isNullOrEmpty()) + !pinUrl.isNullOrEmpty() || ( + !healthCardAndPinMail.isNullOrEmpty() && + !bodyPinMail.isNullOrEmpty() && !subjectPinMail.isNullOrEmpty() + ) fun hasContactInfoForHealthCardAndPin() = !healthCardAndPinPhone.isNullOrEmpty() || diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt index 4e129d30..7cef566b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt @@ -49,6 +49,16 @@ class PharmacyRemoteDataSource( searchService.searchByBundle(bundleId = bundleId, offset = offset, count = count) } + suspend fun searchBinaryCert( + locationId: String + ): Result = safeApiCall("error searching binary") { + if (locationId.startsWith("Location/")) { + searchService.searchBinary(locationId = locationId) + } else { + searchService.searchBinary(locationId = "Location/$locationId") + } + } + suspend fun redeemPrescription( url: String, message: ByteArray, diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt index a4dc61d5..9dc5489f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt @@ -22,6 +22,7 @@ import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import kotlinx.coroutines.flow.flowOn import de.gematik.ti.erp.app.fhir.model.PharmacyServices +import de.gematik.ti.erp.app.fhir.model.extractBinaryCertificateAsBase64 import de.gematik.ti.erp.app.fhir.model.extractPharmacyServices import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData import io.github.aakira.napier.Napier @@ -56,20 +57,34 @@ class PharmacyRepository @Inject constructor( offset: Int, count: Int ): Result = - remoteDataSource.searchPharmaciesContinued( - bundleId = bundleId, - offset = offset, - count = count - ).map { - dispatchers - extractPharmacyServices( - bundle = it, - onError = { element, cause -> - Napier.e(cause) { - element.toString() + withContext(dispatchers.IO) { + remoteDataSource.searchPharmaciesContinued( + bundleId = bundleId, + offset = offset, + count = count + ).map { + extractPharmacyServices( + bundle = it, + onError = { element, cause -> + Napier.e(cause) { + element.toString() + } } - } - ) + ) + } + } + + suspend fun searchBinaryCert( + locationId: String + ): Result = + withContext(dispatchers.IO) { + remoteDataSource.searchBinaryCert( + locationId = locationId + ).map { + extractBinaryCertificateAsBase64( + bundle = it + ) + } } suspend fun redeemPrescription( diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt deleted file mode 100644 index 0a15b3e7..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2023 gematik GmbH - * - * 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 de.gematik.ti.erp.app.pharmacy.repository.model - -import androidx.lifecycle.ViewModel -import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData -import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf - -class PharmacyOverviewViewModel( - private val pharmacyUseCase: PharmacyOverviewUseCase -) : ViewModel() { - fun pharmacyOverviewState() = combine( - pharmacyUseCase.favoritePharmacies(), - pharmacyUseCase.oftenUsedPharmacies() - ) { favorites, oftenUsed -> - favorites + oftenUsed.filterNot { oftenUsedPharmacy -> - favorites.any { - it.telematikId == oftenUsedPharmacy.telematikId - } - } - } - - suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { - pharmacyUseCase.deleteOverviewPharmacy(overviewPharmacy) - } - - suspend fun findPharmacyByTelematikIdState( - telematikId: String - ) = flowOf(pharmacyUseCase.searchPharmacyByTelematikId(telematikId)) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/Favorites.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/Favorites.kt index 2a053112..9f0cca41 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/Favorites.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/Favorites.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyOverviewViewModel import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults @@ -78,7 +77,7 @@ private sealed interface RefreshState { fun FavoritePharmacyCard( overviewPharmacy: OverviewPharmacyData.OverviewPharmacy, onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, - pharmacyViewModel: PharmacyOverviewViewModel + pharmacySearchController: PharmacySearchController ) { var showFailedPharmacyCallDialog by remember { mutableStateOf(false) } var showNoInternetConnectionDialog by remember { mutableStateOf(false) } @@ -86,7 +85,7 @@ fun FavoritePharmacyCard( var state by remember { mutableStateOf(RefreshState.Loading) } LaunchedEffect(overviewPharmacy) { refresh( - pharmacyViewModel = pharmacyViewModel, + pharmacySearchController = pharmacySearchController, pharmacyTelematikId = overviewPharmacy.telematikId, onStateChange = { state = it @@ -106,7 +105,7 @@ fun FavoritePharmacyCard( onClickAction = { scope.launch { refresh( - pharmacyViewModel = pharmacyViewModel, + pharmacySearchController = pharmacySearchController, pharmacyTelematikId = overviewPharmacy.telematikId, onStateChange = { state = it @@ -121,7 +120,7 @@ fun FavoritePharmacyCard( header = stringResource(R.string.pharmacy_search_apovz_call_failed_header), info = stringResource(R.string.pharmacy_search_apovz_call_failed_body), onClickAccept = { - scope.launch { pharmacyViewModel.deleteOverviewPharmacy(overviewPharmacy) } + scope.launch { pharmacySearchController.deleteOverviewPharmacy(overviewPharmacy) } showFailedPharmacyCallDialog = false }, acceptText = stringResource(R.string.pharmacy_search_apovz_call_failed_accept) @@ -149,12 +148,12 @@ fun FavoritePharmacyCard( } private suspend fun refresh( - pharmacyViewModel: PharmacyOverviewViewModel, + pharmacySearchController: PharmacySearchController, pharmacyTelematikId: String, onStateChange: (RefreshState) -> Unit ) { onStateChange(RefreshState.Loading) - val result = pharmacyViewModel.findPharmacyByTelematikIdState(pharmacyTelematikId).first().fold( + val result = pharmacySearchController.findPharmacyByTelematikIdState(pharmacyTelematikId).first().fold( onFailure = { Napier.e("Could not find pharmacy by telematikId", it) RefreshState.Error diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt index b378c379..473a1efb 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt @@ -25,7 +25,9 @@ import android.content.pm.PackageManager import android.location.LocationManager import android.os.Build import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,7 +45,9 @@ import com.google.android.gms.tasks.CancellationTokenSource import de.gematik.ti.erp.app.fhir.model.DeliveryPharmacyService import de.gematik.ti.erp.app.fhir.model.Location import de.gematik.ti.erp.app.fhir.model.isOpenAt +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyMapsUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState @@ -55,9 +59,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -75,16 +81,11 @@ import org.kodein.di.compose.rememberInstance private const val WaitForLocationUpdate = 2500L private const val DefaultRadiusInMeter = 999 * 1000.0 -private val DefaultSearchData = PharmacyUseCaseData.SearchData( - name = "", - filter = PharmacyUseCaseData.Filter(), - locationMode = PharmacyUseCaseData.LocationMode.Disabled -) - @Stable class PharmacySearchController( private val context: Context, private val mapsUseCase: PharmacyMapsUseCase, + private val pharmacyOverviewUseCase: PharmacyOverviewUseCase, private val searchUseCase: PharmacySearchUseCase, coroutineScope: CoroutineScope ) { @@ -102,7 +103,7 @@ class PharmacySearchController( var isLoading by mutableStateOf(false) private set - var searchState by mutableStateOf(DefaultSearchData) + var searchState by mutableStateOf(PharmacySearchStateData.defaultSearchData) private set @OptIn(ExperimentalCoroutinesApi::class) @@ -289,6 +290,29 @@ class PharmacySearchController( } } } + + private val pharmacyOverviewFlow = combine( + pharmacyOverviewUseCase.favoritePharmacies(), + pharmacyOverviewUseCase.oftenUsedPharmacies() + ) { favorites, oftenUsed -> + favorites + oftenUsed.filterNot { oftenUsedPharmacy -> + favorites.any { + it.telematikId == oftenUsedPharmacy.telematikId + } + } + }.map { PharmacySearchStateData.PharmacySearchOverviewState(it) } + + val pharmacySearchOverviewState + @Composable + get() = pharmacyOverviewFlow.collectAsState(PharmacySearchStateData.defaultOverviewPharmacies) + + suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + pharmacyOverviewUseCase.deleteOverviewPharmacy(overviewPharmacy) + } + + suspend fun findPharmacyByTelematikIdState( + telematikId: String + ) = flowOf(pharmacyOverviewUseCase.searchPharmacyByTelematikId(telematikId)) } private fun isLocationServiceEnabled(context: Context): Boolean { @@ -335,12 +359,14 @@ fun rememberPharmacySearchController(): PharmacySearchController { val context = LocalContext.current val pharmacyMapsUseCase by rememberInstance() val pharmacySearchUseCase by rememberInstance() + val pharmacyOverviewUseCase by rememberInstance() val scope = rememberCoroutineScope() return remember { PharmacySearchController( context = context, mapsUseCase = pharmacyMapsUseCase, searchUseCase = pharmacySearchUseCase, + pharmacyOverviewUseCase = pharmacyOverviewUseCase, coroutineScope = scope ) } @@ -358,3 +384,20 @@ private fun anyLocationPermissionGranted(context: Context) = it ) == PackageManager.PERMISSION_GRANTED } + +object PharmacySearchStateData { + @Immutable + data class PharmacySearchOverviewState( + val overviewPharmacies: List + ) + + val defaultOverviewPharmacies = PharmacySearchOverviewState( + overviewPharmacies = listOf() + ) + + val defaultSearchData = PharmacyUseCaseData.SearchData( + name = "", + filter = PharmacyUseCaseData.Filter(), + locationMode = PharmacyUseCaseData.LocationMode.Disabled + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt index 88e06484..36a448a7 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt @@ -54,7 +54,6 @@ import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -78,7 +77,6 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyOverviewViewModel import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold @@ -88,7 +86,6 @@ import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerSmall import kotlinx.coroutines.launch -import org.kodein.di.compose.rememberViewModel private const val LastUsedPharmaciesListLength = 5 @@ -116,7 +113,7 @@ fun PharmacyOverviewScreen( navigationMode = if (isNestedNavigation) NavigationBarMode.Back else NavigationBarMode.Close, onBack = onBack ) { - val pharmacyViewModel by rememberViewModel() + val pharmacySearchController = rememberPharmacySearchController() OverviewContent( onSelectPharmacy = { sheetState.show(PharmacySearchSheetContentState.PharmacySelected(it)) @@ -125,7 +122,7 @@ fun PharmacyOverviewScreen( onFilterChange = onFilterChange, searchFilter = filter, onStartSearch = onStartSearch, - pharmacyViewModel = pharmacyViewModel, + pharmacySearchController = pharmacySearchController, onShowFilter = { sheetState.show(PharmacySearchSheetContentState.FilterSelected) }, @@ -185,13 +182,12 @@ private fun OverviewContent( searchFilter: PharmacyUseCaseData.Filter, onFilterChange: (PharmacyUseCaseData.Filter) -> Unit, onStartSearch: () -> Unit, - pharmacyViewModel: PharmacyOverviewViewModel, + pharmacySearchController: PharmacySearchController, onShowFilter: () -> Unit, onShowMaps: () -> Unit ) { - val overviewPharmacyList by produceState(initialValue = listOf()) { - pharmacyViewModel.pharmacyOverviewState().collect { value = it } - } + val pharmacySearchState by pharmacySearchController.pharmacySearchOverviewState + val overviewPharmacyList = pharmacySearchState.overviewPharmacies val contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom) .add(WindowInsets(top = PaddingDefaults.Medium, bottom = PaddingDefaults.Medium)).asPaddingValues() @@ -246,7 +242,7 @@ private fun OverviewContent( OverviewPharmacies( oftenUsedPharmacyList = overviewPharmacyList, onSelectPharmacy = onSelectPharmacy, - pharmacyViewModel = pharmacyViewModel + pharmacySearchController = pharmacySearchController ) } } @@ -280,7 +276,7 @@ private fun MapsSection( private fun OverviewPharmacies( oftenUsedPharmacyList: List, onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, - pharmacyViewModel: PharmacyOverviewViewModel + pharmacySearchController: PharmacySearchController ) { if (oftenUsedPharmacyList.isNotEmpty()) { Column( @@ -302,7 +298,7 @@ private fun OverviewPharmacies( FavoritePharmacyCard( overviewPharmacy = oftenUsedPharmacy, onSelectPharmacy = onSelectPharmacy, - pharmacyViewModel = pharmacyViewModel + pharmacySearchController = pharmacySearchController ) SpacerMedium() } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt index 72fbb90f..634e0356 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt @@ -21,11 +21,18 @@ package de.gematik.ti.erp.app.pharmacy.usecase import de.gematik.ti.erp.app.pharmacy.buildDirectPharmacyMessage import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.util.encoders.Base64 import java.util.UUID class PharmacyDirectRedeemUseCase( private val repository: PharmacyRepository ) { + suspend fun loadCertificate(locationId: String): Result = + repository + .searchBinaryCert(locationId = locationId) + .mapCatching { base64Cert -> + X509CertificateHolder(Base64.decode(base64Cert)) + } suspend fun redeemPrescription( url: String, diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/PkvModule.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/PkvModule.kt index 3f894718..147c659f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pkv/PkvModule.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/PkvModule.kt @@ -21,6 +21,10 @@ package de.gematik.ti.erp.app.pkv import de.gematik.ti.erp.app.consent.repository.ConsentRemoteDataSource import de.gematik.ti.erp.app.consent.repository.ConsentRepository import de.gematik.ti.erp.app.consent.usecase.ConsentUseCase +import de.gematik.ti.erp.app.invoice.repository.InvoiceLocalDataSource +import de.gematik.ti.erp.app.invoice.repository.InvoiceRemoteDataSource +import de.gematik.ti.erp.app.invoice.repository.InvoiceRepository +import de.gematik.ti.erp.app.invoice.usecase.InvoiceUseCase import org.kodein.di.DI import org.kodein.di.bindProvider @@ -28,6 +32,11 @@ import org.kodein.di.instance val pkvModule = DI.Module("pkvModule") { bindProvider { ConsentUseCase(instance()) } - bindProvider { ConsentRemoteDataSource(instance()) } bindProvider { ConsentRepository(instance(), instance()) } + bindProvider { ConsentRemoteDataSource(instance()) } + + bindProvider { InvoiceUseCase(instance(), instance()) } + bindProvider { InvoiceRepository(instance(), instance(), instance()) } + bindProvider { InvoiceRemoteDataSource(instance()) } + bindProvider { InvoiceLocalDataSource(instance()) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDetailsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDetailsScreen.kt new file mode 100644 index 00000000..2038b2ec --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDetailsScreen.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.visualTestTag + +import de.gematik.ti.erp.app.invoice.model.currencyString +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.utils.compose.LabeledText +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge + +@Composable +fun InvoiceDetailsScreen( + invoicesController: InvoicesController, + taskId: String, + onBack: () -> Unit +) { + val listState = rememberLazyListState() + val scaffoldState = rememberScaffoldState() + + val invoice by produceState(null) { + invoicesController.detailState(taskId).collect { + value = it + } + } + + AnimatedElevationScaffold( + modifier = Modifier + .imePadding() + .visualTestTag(TestTag.Profile.InvoicesDetailScreen), + topBarTitle = "", + navigationMode = NavigationBarMode.Back, + scaffoldState = scaffoldState, + listState = listState, + actions = {}, + onBack = onBack + ) { innerPadding -> + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding( + top = PaddingDefaults.Medium + innerPadding.calculateTopPadding(), + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) + ) { + invoice?.let { + item { + InvoiceMedicationHeader(it) + } + item { + LabeledText(description = stringResource(R.string.invoice_task_id), content = it.taskId) + } + item { + PatientLabel(it.patient) + } + item { + PractitionerLabel(it.practitioner, it.practitionerOrganization) + } + item { + PharmacyLabel(it.pharmacyOrganization) + } + item { + LabeledText( + description = stringResource(R.string.invoice_redeemed_on), + content = it.whenHandedOver?.formattedString() + ) + } + item { + PriceData(it.invoice) + } + } + } + } +} + +@Composable +fun PriceData(invoice: InvoiceData.Invoice) { + val (fees, articles) = invoice.chargeableItems.partition { + (it.description as? InvoiceData.ChargeableItem.Description.PZN)?.isSpecialPZN() ?: false + } + + articles.map { + val article = when (it.description) { + is InvoiceData.ChargeableItem.Description.HMNR -> + stringResource( + R.string.invoice_description_hmknr, + (it.description as InvoiceData.ChargeableItem.Description.HMNR).hmnr + ) + is InvoiceData.ChargeableItem.Description.PZN -> + stringResource( + R.string.invoice_description_pzn, + (it.description as InvoiceData.ChargeableItem.Description.PZN).pzn + ) + is InvoiceData.ChargeableItem.Description.TA1 -> + stringResource( + R.string.invoice_description_ta1, + (it.description as InvoiceData.ChargeableItem.Description.TA1).ta1 + ) + } + + Text(stringResource(R.string.invoice_description_articel, article)) + Text(stringResource(R.string.invoice_description_factor, it.factor)) + Text(stringResource(R.string.invoice_description_tax, it.price.tax.currencyString())) + Text(stringResource(R.string.invoice_description_brutto_price, it.price.value)) + + SpacerMedium() + } + + if (fees.isNotEmpty()) { + Text(stringResource(R.string.invoice_description_additional_fees)) + fees.map { + require(it.description is InvoiceData.ChargeableItem.Description.PZN) + val article = when ( + InvoiceData.SpecialPZN.valueOfPZN( + (it.description as InvoiceData.ChargeableItem.Description.PZN).pzn + ) + ) { + InvoiceData.SpecialPZN.EmergencyServiceFee -> stringResource(R.string.invoice_details_emergency_fee) + InvoiceData.SpecialPZN.BTMFee -> stringResource(R.string.invoice_details_narcotic_fee) + InvoiceData.SpecialPZN.TPrescriptionFee -> stringResource(R.string.invoice_details_t_prescription_fee) + InvoiceData.SpecialPZN.ProvisioningCosts -> stringResource(R.string.invoice_details_provisioning_costs) + InvoiceData.SpecialPZN.DeliveryServiceCosts -> + stringResource(R.string.invoice_details_delivery_service_costs) + null -> error("wrong mapping") + } + + Text(stringResource(R.string.invoice_description_articel, article)) + Text(stringResource(R.string.invoice_description_brutto_price, it.price.value)) + + SpacerMedium() + } + } + + Text(stringResource(R.string.invoice_description_total_brutto_amount, invoice.totalBruttoAmount)) + + Text(stringResource(R.string.invoice_detail_dispense), style = AppTheme.typography.body2l) + SpacerXXLarge() +} + +@Composable +fun PharmacyLabel(pharmacyOrganization: SyncedTaskData.Organization) { + LabeledTextItems( + label = stringResource(R.string.invoice_redeemed_in), + items = listOf( + pharmacyOrganization.name, + pharmacyOrganization.address?.joinToString(), + pharmacyOrganization.uniqueIdentifier?.let { stringResource(R.string.invoice_pharmacy_id, it) } + ) + ) +} + +@Composable +fun PractitionerLabel( + practitioner: SyncedTaskData.Practitioner, + practitionerOrganization: SyncedTaskData.Organization +) { + LabeledTextItems( + label = stringResource(R.string.invoice_prescribed_by), + items = listOf( + practitioner.name, + practitionerOrganization.address?.joinToString(), + practitioner.practitionerIdentifier?.let { stringResource(R.string.invoice_practitioner_id, it) } + ) + ) +} + +@Composable +fun LabeledTextItems(label: String, items: List) { + val showLabel = items.any { it != null } + items.forEach { + it?.let { + Text(it, style = AppTheme.typography.body1) + } + } + if (showLabel) { + Text(label, style = AppTheme.typography.body2l) + } +} + +@Composable +fun PatientLabel(patient: SyncedTaskData.Patient) { + LabeledTextItems( + label = stringResource(R.string.invoice_prescribed_for), + items = listOf( + patient.name, + patient.insuranceIdentifier?.let { stringResource(R.string.invoice_insurance_id, it) }, + patient.address?.joinToString(), + patient.birthdate?.formattedString()?.let { stringResource(R.string.invoice_born_on, it) } + ) + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDialogues.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDialogues.kt new file mode 100644 index 00000000..8511bd7d --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceDialogues.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog + +@Composable +fun RevokeConsentDialog(onCancel: () -> Unit, onRevokeConsent: () -> Unit) { + CommonAlertDialog( + header = stringResource(R.string.profile_revoke_consent_header), + info = stringResource(R.string.profile_revoke_consent_info), + cancelText = stringResource(R.string.profile_invoices_cancel), + actionText = stringResource(R.string.profile_revoke_consent), + cancelTextColor = AppTheme.colors.primary600, + actionTextColor = AppTheme.colors.red600, + onCancel = onCancel, + onClickAction = onRevokeConsent + ) +} + +@Composable +fun DeleteInvoiceDialog(onCancel: () -> Unit, onDeleteInvoice: () -> Unit) { + CommonAlertDialog( + header = stringResource(R.string.profile_delete_invoice_header), + info = stringResource(R.string.profile_delete_invoice_info), + cancelText = stringResource(R.string.profile_invoices_cancel), + actionText = stringResource(R.string.profile_delete_invoice), + cancelTextColor = AppTheme.colors.primary600, + actionTextColor = AppTheme.colors.red600, + onCancel = onCancel, + onClickAction = onDeleteInvoice + ) +} + +@Composable +fun GrantConsentDialog(onCancel: () -> Unit, onGrantConsent: () -> Unit) { + CommonAlertDialog( + header = stringResource(R.string.profile_grant_consent_header), + info = stringResource(R.string.profile_grant_consent_info), + cancelText = stringResource(R.string.profile_invoices_cancel), + actionText = stringResource(R.string.profile_grant_consent), + onCancel = onCancel, + onClickAction = onGrantConsent + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceInformationScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceInformationScreen.kt index 4beeedcb..8506cf20 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceInformationScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceInformationScreen.kt @@ -18,7 +18,6 @@ package de.gematik.ti.erp.app.pkv.ui -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,212 +27,200 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Button import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.SnackbarHost import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.TestTag -import de.gematik.ti.erp.app.mainscreen.ui.MainScreenController -import de.gematik.ti.erp.app.mainscreen.ui.RefreshScaffold -import de.gematik.ti.erp.app.pkv.ui.ConsentController.State.ChargeConsentNotGranted.isConsentGranted -import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState -import de.gematik.ti.erp.app.prescription.ui.rememberRefreshPrescriptionsController +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.invoice.model.PkvHtmlTemplate.joinMedicationInfo import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold -import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.NavigationBarMode import de.gematik.ti.erp.app.utils.compose.visualTestTag -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch + +import androidx.compose.ui.text.font.FontWeight + +import de.gematik.ti.erp.app.utils.compose.LabeledText +import de.gematik.ti.erp.app.utils.compose.TertiaryButton @Composable fun InvoiceInformationScreen( - mainScreenController: MainScreenController, - onBack: () -> Unit, selectedProfile: ProfilesUseCaseData.Profile, - onShowCardWall: () -> Unit + taskId: String, + onBack: () -> Unit, + onClickShowMore: () -> Unit, + onClickSubmit: () -> Unit ) { val listState = rememberLazyListState() val scaffoldState = rememberScaffoldState() val scope = rememberCoroutineScope() - - val consentController = rememberConsentController(profile = selectedProfile) - - val ssoTokenValid by remember(selectedProfile) { - derivedStateOf { - selectedProfile.ssoTokenValid() - } - } - - var consentGranted by remember { mutableStateOf(false) } - - var showGrantConsentDialog by remember { mutableStateOf(false) } - val context = LocalContext.current - LaunchedEffect(Unit) { - if (ssoTokenValid) { - val consentState = consentController.getChargeConsent().first() - when (consentState) { - is PrescriptionServiceErrorState -> { - consentErrorMessage(context, consentState)?.let { - scaffoldState.snackbarHostState.showSnackbar(it) - } - } - } - consentGranted = consentState.isConsentGranted() - showGrantConsentDialog = !consentGranted + val invoicesController = rememberInvoicesController(profileId = selectedProfile.id) + val invoice by produceState(null) { + invoicesController.detailState(taskId).collect { + value = it } } + var showDeleteInvoiceAlert by remember { mutableStateOf(false) } - var connectBottomBarVisible by remember { mutableStateOf(!ssoTokenValid) } - - var showRevokeConsentAlert by remember { mutableStateOf(false) } - - if (showGrantConsentDialog && selectedProfile.ssoTokenValid()) { - GrantConsentDialog( - onCancel = onBack, - onGrantConsent = { - scope.launch { - val consentState = consentController.grantChargeConsent().first() - when (consentState) { - is PrescriptionServiceErrorState -> { - consentErrorMessage(context, consentState)?.let { - scaffoldState.snackbarHostState.showSnackbar(it) - } - } - } - consentGranted = consentState.isConsentGranted() - showGrantConsentDialog = false - } + if (showDeleteInvoiceAlert) { + DeleteInvoiceDialog( + onCancel = { + showDeleteInvoiceAlert = false } - ) - } - - if (showRevokeConsentAlert) { - RevokeConsentDialog( - onCancel = { showRevokeConsentAlert = false }, - onRevokeConsent = { - scope.launch { - val consentState = consentController.revokeChargeConsent().first() - when (consentState) { - is PrescriptionServiceErrorState -> { - consentErrorMessage(context, consentState)?.let { - scaffoldState.snackbarHostState.showSnackbar(it) - } - } - } - consentGranted = consentState.isConsentGranted() - onBack() - } + ) { + onDeleteInvoice( + scope, + taskId, + invoicesController, + selectedProfile, + context, + scaffoldState + ) { + showDeleteInvoiceAlert = false + onBack() } - ) + } } - val refreshPrescriptionsController = rememberRefreshPrescriptionsController(mainScreenController) - AnimatedElevationScaffold( modifier = Modifier .imePadding() - .visualTestTag(TestTag.Profile.InvoicesScreen), - topBarTitle = stringResource(R.string.profile_invoices), - snackbarHost = { - SnackbarHost(it, modifier = Modifier.systemBarsPadding()) - }, + .visualTestTag(TestTag.Profile.InvoicesDetailScreen), + topBarTitle = "", + navigationMode = NavigationBarMode.Back, + scaffoldState = scaffoldState, bottomBar = { - if (connectBottomBarVisible) { - ConnectBottomBar { - scope.launch { - refreshPrescriptionsController.refresh( - profileId = selectedProfile.id, - isUserAction = true, - onUserNotAuthenticated = { connectBottomBarVisible = true }, - onShowCardWall = onShowCardWall - ) - } - } + invoice?.let { + InvoiceDetailBottomBar( + it.invoice.totalBruttoAmount, + onClickSubmit = onClickSubmit + ) } }, - navigationMode = NavigationBarMode.Back, - scaffoldState = scaffoldState, listState = listState, actions = { - InvoicesThreeDotMenu( - consentGranted = consentGranted, - onClickRevokeConsent = { showRevokeConsentAlert = true } - ) + Row { + InvoicesDetailThreeDotMenu( + onClickDelete = { showDeleteInvoiceAlert = true } + ) + } }, onBack = onBack - ) { - RefreshScaffold( - profileId = selectedProfile.id, - onUserNotAuthenticated = { connectBottomBarVisible = true }, - mainScreenController = mainScreenController, - onShowCardWall = {} - ) { _ -> - Invoices( - listState = listState - ) + ) { innerPadding -> + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding( + top = PaddingDefaults.Medium + innerPadding.calculateTopPadding(), + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) + ) { + invoice?.let { + item { + InvoiceMedicationHeader(it) + } + item { + LabeledText( + description = stringResource(R.string.invoice_prescribed_by), + content = it.practitioner.name + ) + } + item { + LabeledText( + description = stringResource(R.string.invoice_redeemed_in), + content = it.pharmacyOrganization.name + ) + } + item { + LabeledText( + description = stringResource(R.string.invoice_redeemed_on), + content = it.whenHandedOver?.formattedString() + ) + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TertiaryButton(onClick = onClickShowMore) { + Text(text = stringResource(R.string.invoice_show_more)) + } + } + } + } } } } @Composable -fun RevokeConsentDialog(onCancel: () -> Unit, onRevokeConsent: () -> Unit) { - CommonAlertDialog( - header = stringResource(R.string.profile_revoke_consent_header), - info = stringResource(R.string.profile_revoke_consent_info), - cancelText = stringResource(R.string.profile_invoices_cancel), - actionText = stringResource(R.string.profile_revoke_consent), - onCancel = onCancel, - onClickAction = onRevokeConsent - ) +fun InvoiceDetailBottomBar(totalBruttoAmount: Double, onClickSubmit: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .background( + color = AppTheme.colors.neutral100 + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { + Text( + stringResource(R.string.invoice_details_cost, totalBruttoAmount), + style = AppTheme.typography.h6, + fontWeight = FontWeight.Bold + ) + Text(stringResource(R.string.invoice_detail_total_brutto_amount), style = AppTheme.typography.body2l) + } + + Button( + onClick = onClickSubmit, + modifier = Modifier.padding(end = PaddingDefaults.Medium) + ) { + Text(text = stringResource(R.string.invoice_details_submit)) + } + } } @Composable -fun GrantConsentDialog(onCancel: () -> Unit, onGrantConsent: () -> Unit) { - CommonAlertDialog( - header = stringResource(R.string.profile_grant_consent_header), - info = stringResource(R.string.profile_grant_consent_info), - cancelText = stringResource(R.string.profile_invoices_cancel), - actionText = stringResource(R.string.profile_grant_consent), - onCancel = onCancel, - onClickAction = onGrantConsent - ) +fun InvoiceMedicationHeader(invoice: InvoiceData.PKVInvoice) { + val medicationInfo = joinMedicationInfo(invoice.medicationRequest) + Text(text = medicationInfo, style = AppTheme.typography.h5) } @Composable -fun InvoicesThreeDotMenu(consentGranted: Boolean, onClickRevokeConsent: () -> Unit) { +fun InvoicesDetailThreeDotMenu(onClickDelete: () -> Unit) { var expanded by remember { mutableStateOf(false) } IconButton( @@ -248,79 +235,14 @@ fun InvoicesThreeDotMenu(consentGranted: Boolean, onClickRevokeConsent: () -> Un ) { DropdownMenuItem( onClick = { - onClickRevokeConsent() + onClickDelete() expanded = false - }, - enabled = consentGranted + } ) { Text( - text = - stringResource(R.string.profile_revoke_consent) + text = stringResource(R.string.invoice_detail_delete), + color = AppTheme.colors.red600 ) } } } - -@Composable -fun Invoices( - listState: LazyListState -) { - LazyColumn( - modifier = Modifier - .fillMaxSize(), - state = listState - ) { - item { - InvoicesEmptyScreen() - } - } -} - -@Composable -fun LazyItemScope.InvoicesEmptyScreen() { - Column( - modifier = Modifier - .fillParentMaxSize() - .padding(PaddingDefaults.Medium), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Image( - painterResource(R.drawable.girl_red_oh_no), - contentDescription = null - ) - Text( - stringResource(R.string.invoices_no_invoices), - style = AppTheme.typography.subtitle1, - textAlign = TextAlign.Center - ) - } -} - -@Composable -fun ConnectBottomBar(onClickConnect: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .background( - color = AppTheme.colors.primary100 - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.invoices_connect_info), - modifier = Modifier - .padding(PaddingDefaults.Medium) - .weight(1f), - style = AppTheme.typography.body2 - ) - Button( - onClick = onClickConnect, - modifier = Modifier.padding(end = PaddingDefaults.Medium) - ) { - Text(text = stringResource(R.string.invoices_connect_btn)) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceOverviewScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceOverviewScreen.kt new file mode 100644 index 00000000..a9f24bd5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoiceOverviewScreen.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.visualTestTag + +import de.gematik.ti.erp.app.utils.compose.LabeledText +import de.gematik.ti.erp.app.utils.compose.TertiaryButton + +@Composable +fun InvoiceOverviewScreen( + selectedProfile: ProfilesUseCaseData.Profile, + taskId: String, + onBack: () -> Unit, + onClickShowMore: () -> Unit, + onClickSubmit: () -> Unit +) { + val listState = rememberLazyListState() + val scaffoldState = rememberScaffoldState() + val invoicesController = rememberInvoicesController(profileId = selectedProfile.id) + val invoice by produceState(null) { + invoicesController.detailState(taskId).collect { + value = it + } + } + var showDeleteInvoiceAlert by remember { mutableStateOf(false) } + + AnimatedElevationScaffold( + modifier = Modifier + .imePadding() + .visualTestTag(TestTag.Profile.InvoicesDetailScreen), + topBarTitle = "", + navigationMode = NavigationBarMode.Back, + scaffoldState = scaffoldState, + bottomBar = { + invoice?.let { + InvoiceDetailBottomBar( + it.invoice.totalBruttoAmount, + onClickSubmit = onClickSubmit + ) + } + }, + listState = listState, + actions = { + Row { + InvoicesDetailThreeDotMenu( + onClickDelete = { showDeleteInvoiceAlert = true } + ) + } + }, + onBack = onBack + ) { innerPadding -> + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding( + top = PaddingDefaults.Medium + innerPadding.calculateTopPadding(), + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) + ) { + invoice?.let { + item { + InvoiceMedicationHeader(it) + } + item { + LabeledText( + description = stringResource(R.string.invoice_prescribed_by), + content = it.practitioner.name + ) + } + item { + LabeledText( + description = stringResource(R.string.invoice_redeemed_in), + content = it.pharmacyOrganization.name + ) + } + item { + LabeledText( + description = stringResource(R.string.invoice_redeemed_on), + content = it.whenHandedOver?.formattedString() + ) + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TertiaryButton(onClick = onClickShowMore) { + Text(text = stringResource(R.string.invoice_show_more)) + } + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesController.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesController.kt new file mode 100644 index 00000000..13debf58 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesController.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.cardwall.mini.ui.Authenticator +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.fhir.parser.asFhirTemporal +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.invoice.model.PkvHtmlTemplate +import de.gematik.ti.erp.app.invoice.usecase.InvoiceUseCase +import de.gematik.ti.erp.app.invoice.usecase.createSharableFileInCache +import de.gematik.ti.erp.app.invoice.usecase.sharePDFFile +import de.gematik.ti.erp.app.invoice.usecase.writePDFAttachments +import de.gematik.ti.erp.app.invoice.usecase.writePdfFromHtml +import de.gematik.ti.erp.app.prescription.ui.GeneralErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.RefreshedState +import de.gematik.ti.erp.app.prescription.ui.catchAndTransformRemoteExceptions +import de.gematik.ti.erp.app.prescription.ui.retryWithAuthenticator +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.kodein.di.compose.rememberInstance +import java.net.HttpURLConnection + +class InvoicesController( + profileId: ProfileIdentifier, + private val invoiceUseCase: InvoiceUseCase, + private val authenticator: Authenticator + +) { + + sealed interface State : PrescriptionServiceState { + + object InvoiceDeleted : State + + sealed interface Error : State, PrescriptionServiceErrorState { + object InvoiceAlreadyDeleted : Error + } + fun PrescriptionServiceState.isInvoiceDeleted() = + this == InvoiceDeleted || this == Error.InvoiceAlreadyDeleted + } + + private val stateFlow: Flow>> = + invoiceUseCase.invoices(profileId) + + val state + @Composable + get() = stateFlow.collectAsState(null) + + fun detailState(taskId: String): Flow = + invoiceUseCase.invoiceById(taskId) + + val isRefreshing + @Composable + get() = invoiceUseCase.refreshInProgress.collectAsState() + + fun downloadInvoices( + profileId: ProfileIdentifier + ): Flow = + invoiceUseCase.downloadInvoices(profileId) + .map { + RefreshedState(it) + } + .retryWithAuthenticator( + isUserAction = true, + authenticate = authenticator.authenticateForPrescriptions(profileId) + ) + .catchAndTransformRemoteExceptions() + .flowOn(Dispatchers.IO) + + suspend fun shareInvoicePDF(context: Context, invoice: InvoiceData.PKVInvoice) { + val html = PkvHtmlTemplate.createHTML(invoice) + + val file = createSharableFileInCache(context, "invoices", "invoice") + writePdfFromHtml(context, "Invoice_${invoice.taskId}", html, file) + invoiceUseCase.loadAttachments(invoice.taskId)?.let { + writePDFAttachments(file, it) + } + val subject = invoice.medicationRequest.medication?.name() + "_" + + invoice.timestamp.asFhirTemporal().formattedString() + sharePDFFile(context, file, subject) + } + + suspend fun deleteInvoice( + profileId: ProfileIdentifier, + taskId: String + ): PrescriptionServiceState = + deleteInvoiceFlow(profileId = profileId, taskId = taskId).cancellable().first() + + private fun deleteInvoiceFlow(profileId: ProfileIdentifier, taskId: String) = + flow { + emit(invoiceUseCase.deleteInvoice(profileId = profileId, taskId = taskId)) + }.map { result -> + result.fold( + onSuccess = { + State.InvoiceDeleted + }, + onFailure = { + if (it is ApiCallException) { + when (it.response.code()) { + HttpURLConnection.HTTP_NOT_FOUND, + HttpURLConnection.HTTP_GONE -> State.Error.InvoiceAlreadyDeleted + else -> throw it + } + } else { + throw it + } + } + ) + }.retryWithAuthenticator( + isUserAction = true, + authenticate = authenticator.authenticateForPrescriptions(profileId) + ) + .catchAndTransformRemoteExceptions() + .flowOn(Dispatchers.IO) +} + +@Composable +fun rememberInvoicesController(profileId: ProfileIdentifier): InvoicesController { + val invoiceUseCase by rememberInstance() + val authenticator = LocalAuthenticator.current + + return remember { + InvoicesController( + profileId = profileId, + invoiceUseCase = invoiceUseCase, + authenticator = authenticator + ) + } +} + +fun refreshInvoicesErrorMessage(context: Context, errorState: PrescriptionServiceErrorState): String? = + when (errorState) { + GeneralErrorState.NetworkNotAvailable -> + context.getString(R.string.error_message_network_not_available) + + is GeneralErrorState.ServerCommunicationFailedWhileRefreshing -> + context.getString(R.string.error_message_server_communication_failed).format(errorState.code) + + GeneralErrorState.FatalTruststoreState -> + context.getString(R.string.error_message_vau_error) + + else -> null + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesScreen.kt new file mode 100644 index 00000000..be7e4d37 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/InvoicesScreen.kt @@ -0,0 +1,684 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ScaffoldState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.invoice.model.currencyString +import de.gematik.ti.erp.app.mainscreen.ui.MainScreenController +import de.gematik.ti.erp.app.pkv.ui.ConsentController.State.ChargeConsentNotGranted.isConsentGranted +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.rememberRefreshPrescriptionsController +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.SpacerXLarge +import de.gematik.ti.erp.app.utils.compose.visualTestTag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone.Companion.currentSystemDefault +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +fun InvoicesScreen( + mainScreenController: MainScreenController, + onBack: () -> Unit, + selectedProfile: ProfilesUseCaseData.Profile, + onShowCardWall: () -> Unit, + onClickInvoice: (String) -> Unit, + onClickShare: (String) -> Unit, + invoicesController: InvoicesController +) { + val listState = rememberLazyListState() + val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() + val consentController = rememberConsentController(profile = selectedProfile) + var consentGranted by remember { mutableStateOf(false) } + var showGrantConsentDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + val ssoTokenValid by remember(selectedProfile) { + derivedStateOf { + selectedProfile.ssoTokenValid() + } + } + + CheckConsentState(consentController, ssoTokenValid, scaffoldState, context) { + consentGranted = it + showGrantConsentDialog = !consentGranted + } + + var connectBottomBarVisible by remember { mutableStateOf(!ssoTokenValid) } + + var showRevokeConsentAlert by remember { mutableStateOf(false) } + + if (showGrantConsentDialog && selectedProfile.ssoTokenValid()) { + GrantConsentDialog( + onCancel = onBack + ) { + onGrantConsent(context, scope, consentController, scaffoldState) { + consentGranted = it + showGrantConsentDialog = false + } + } + } + + if (showRevokeConsentAlert) { + RevokeConsentDialog( + onCancel = { showRevokeConsentAlert = false } + ) { + onRevokeConsent(context, scope, consentController, scaffoldState, { consentGranted = it }) { + onBack() + } + } + } + + var showDeleteInvoiceAlert by remember { mutableStateOf(false) } + var invoiceToDeleteTaskID: String? by remember { mutableStateOf(null) } + + if (showDeleteInvoiceAlert) { + DeleteInvoiceDialog( + onCancel = { + showDeleteInvoiceAlert = false + invoiceToDeleteTaskID = null + } + ) { + onDeleteInvoice( + scope, + invoiceToDeleteTaskID, + invoicesController, + selectedProfile, + context, + scaffoldState + ) { + showDeleteInvoiceAlert = false + invoiceToDeleteTaskID = null + } + } + } + + val refreshPrescriptionsController = rememberRefreshPrescriptionsController(mainScreenController) + + AnimatedElevationScaffold( + modifier = Modifier + .imePadding() + .visualTestTag(TestTag.Profile.InvoicesScreen), + topBarTitle = stringResource(R.string.profile_invoices), + snackbarHost = { + SnackbarHost(it, modifier = Modifier.systemBarsPadding()) + }, + bottomBar = { + if (connectBottomBarVisible) { + ConnectBottomBar { + scope.launch { + refreshPrescriptionsController.refresh( + profileId = selectedProfile.id, + isUserAction = true, + onUserNotAuthenticated = { connectBottomBarVisible = true }, + onShowCardWall = onShowCardWall + ) + } + } + } + }, + navigationMode = NavigationBarMode.Back, + scaffoldState = scaffoldState, + listState = listState, + actions = { + Row { + InvoicesHeaderThreeDotMenu( + consentGranted = remember(consentGranted) { consentGranted }, + onClickRevokeConsent = { showRevokeConsentAlert = true } + ) + } + }, + onBack = onBack + ) { + RefreshInvoicesContent( + profileIdentifier = selectedProfile.id, + invoicesController = invoicesController, + ssoTokenValid = ssoTokenValid, + listState = listState, + consentGranted = consentGranted, + onRefreshInvoicesError = { + scope.launch { + scaffoldState.snackbarHostState.showSnackbar(it) + } + }, + onDeleteInvoice = { + invoiceToDeleteTaskID = it + showDeleteInvoiceAlert = true + }, + onClickInvoice = onClickInvoice, + onClickShare = onClickShare + ) + } +} + +@Composable +fun CheckConsentState( + consentController: ConsentController, + ssoTokenValid: Boolean, + scaffoldState: ScaffoldState, + context: Context, + saveConsentState: (Boolean) -> Unit +) { + LaunchedEffect(Unit) { + if (ssoTokenValid) { + val consentState = consentController.getChargeConsent().first() + when (consentState) { + is PrescriptionServiceErrorState -> { + consentErrorMessage(context, consentState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + saveConsentState(consentState.isConsentGranted()) + } + } +} + +fun onGrantConsent( + context: Context, + scope: CoroutineScope, + consentController: ConsentController, + scaffoldState: ScaffoldState, + saveConsentState: (Boolean) -> Unit +) { + scope.launch { + val consentState = consentController.grantChargeConsent().first() + when (consentState) { + is PrescriptionServiceErrorState -> { + consentErrorMessage(context, consentState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + saveConsentState(consentState.isConsentGranted()) + } +} + +fun onRevokeConsent( + context: Context, + scope: CoroutineScope, + consentController: ConsentController, + scaffoldState: ScaffoldState, + saveConsentState: (Boolean) -> Unit, + onBack: () -> Unit +) { + scope.launch { + val consentState = consentController.revokeChargeConsent().first() + when (consentState) { + is PrescriptionServiceErrorState -> { + consentErrorMessage(context, consentState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + saveConsentState(consentState.isConsentGranted()) + onBack() + } +} +fun onDeleteInvoice( + scope: CoroutineScope, + invoiceToDeleteTaskID: String?, + invoicesController: InvoicesController, + selectedProfile: ProfilesUseCaseData.Profile, + context: Context, + scaffoldState: ScaffoldState, + finally: () -> Unit +) { + scope.launch { + val invoiceState = + invoiceToDeleteTaskID?.let { + invoicesController.deleteInvoice( + profileId = selectedProfile.id, + taskId = it + ) + } + when (invoiceState) { + is PrescriptionServiceErrorState -> { + refreshInvoicesErrorMessage(context, invoiceState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + finally() + } +} + +@Composable +@OptIn(ExperimentalMaterialApi::class) +private fun RefreshInvoicesContent( + profileIdentifier: ProfileIdentifier, + invoicesController: InvoicesController, + listState: LazyListState, + ssoTokenValid: Boolean, + consentGranted: Boolean, + onRefreshInvoicesError: (String) -> Unit, + onDeleteInvoice: (String) -> Unit, + onClickInvoice: (String) -> Unit, + onClickShare: (String) -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val isRefreshing by invoicesController.isRefreshing + + fun refresh() = scope.launch { + when (val state = invoicesController.downloadInvoices(profileIdentifier).first()) { + is PrescriptionServiceErrorState -> { + refreshInvoicesErrorMessage(context, state)?.let { + onRefreshInvoicesError(it) + } + } + } + } + + val refreshState = rememberPullRefreshState(isRefreshing, ::refresh) + + LaunchedEffect(ssoTokenValid, consentGranted) { + if (ssoTokenValid && consentGranted) { + refresh() + } + } + + Box( + Modifier + .fillMaxSize() + .pullRefresh(refreshState, enabled = ssoTokenValid && consentGranted) + ) { + PullRefreshIndicator( + refreshing = isRefreshing, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = AppTheme.colors.primary600, + scale = true + ) + Invoices( + listState = listState, + invoicesController = invoicesController, + onDeleteInvoice = onDeleteInvoice, + onClickInvoice = onClickInvoice, + onClickShare = onClickShare + ) + } +} + +@Composable +fun InvoicesHeaderThreeDotMenu(consentGranted: Boolean, onClickRevokeConsent: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + + IconButton( + onClick = { expanded = true } + ) { + Icon(Icons.Rounded.MoreVert, null, tint = AppTheme.colors.neutral600) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(24.dp, 0.dp) + ) { + DropdownMenuItem( + onClick = { + onClickRevokeConsent() + expanded = false + }, + enabled = consentGranted + ) { + Text( + text = stringResource(R.string.profile_revoke_consent), + color = if (consentGranted) { + AppTheme.colors.red600 + } else { + AppTheme.colors.neutral900 + } + ) + } + } +} + +@Composable +fun InvoiceThreeDotMenu( + taskId: String, + onClickShareInvoice: (String) -> Unit, + onClickRemoveInvoice: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + IconButton( + onClick = { expanded = true } + ) { + Icon(Icons.Rounded.MoreVert, null, tint = AppTheme.colors.neutral600) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(24.dp, 0.dp) + ) { + DropdownMenuItem( + onClick = { + onClickShareInvoice(taskId) + expanded = false + } + ) { + Text( + text = stringResource(R.string.invoice_header_share) + ) + } + DropdownMenuItem( + onClick = { + onClickRemoveInvoice(taskId) + expanded = false + } + ) { + Text( + text = stringResource(R.string.invoice_header_delete), + color = AppTheme.colors.red600 + ) + } + } +} + +@Composable +fun Invoices( + listState: LazyListState, + invoicesController: InvoicesController, + onClickInvoice: (String) -> Unit, + onDeleteInvoice: (String) -> Unit, + onClickShare: (String) -> Unit +) { + val invoiceState by invoicesController.state + + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues(), + state = listState + ) { + invoiceState?.let { state -> + if (state.entries.isEmpty()) { + item { + InvoicesEmptyScreen() + } + } else { + state.entries.forEach { entry -> + item { + val formattedYear = remember { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy") + if (entry.value.isNotEmpty()) { + entry.value[0].timestamp + .toLocalDateTime(currentSystemDefault()) + .toJavaLocalDateTime().format(dateFormatter) + } else { + "" + } + } + + val totalSumOfInvoices = entry.value.sumOf { + it.invoice.totalBruttoAmount + }.currencyString() + SpacerMedium() + HeadingPerYear(formattedYear, totalSumOfInvoices, entry.value[0].invoice.currency) + } + entry.value.forEach { invoice -> + item { + val formattedDate = remember { + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + invoice.timestamp + .toLocalDateTime(currentSystemDefault()) + .toJavaLocalDateTime().format(dateFormatter) + } + Invoice( + invoice = invoice, + formattedDate = formattedDate, + onDeleteInvoice = onDeleteInvoice, + onClickShare = onClickShare, + onClickInvoice = onClickInvoice + ) + Divider(modifier = Modifier.padding(start = PaddingDefaults.Medium)) + } + } + item { SpacerXLarge() } + } + } + } + } +} + +@Composable +private fun Invoice( + invoice: InvoiceData.PKVInvoice, + formattedDate: String, + onDeleteInvoice: (String) -> Unit, + onClickShare: (String) -> Unit, + onClickInvoice: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClickInvoice(invoice.taskId) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding( + start = PaddingDefaults.Medium, + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + ).weight(1f) + + ) { + val itemName = invoice.medicationRequest.medication?.name() ?: "" + + Text( + itemName, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.subtitle1, + color = AppTheme.colors.neutral900 + ) + SpacerTiny() + Text( + formattedDate, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.body2l + ) + SpacerSmall() + TotalBruttoAmountChip( + text = invoice.invoice.totalBruttoAmount.currencyString() + " " + invoice.invoice.currency + ) + } + Row(modifier = Modifier, horizontalArrangement = Arrangement.End) { + InvoiceThreeDotMenu( + invoice.taskId, + onClickShareInvoice = onClickShare, + onClickRemoveInvoice = onDeleteInvoice + ) + } + } +} + +@Composable +fun TotalBruttoAmountChip( + text: String, + modifier: Modifier = Modifier +) { + val shape = RoundedCornerShape(8.dp) + + Row( + Modifier + .background(AppTheme.colors.neutral025, shape) + .border(1.dp, AppTheme.colors.neutral300, shape) + .clip(shape) + .then(modifier) + .padding(vertical = 6.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, style = AppTheme.typography.subtitle2, color = AppTheme.colors.neutral900) + } +} + +@Composable +private fun HeadingPerYear( + formattedYear: String, + totalSumOfInvoices: String, + currency: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formattedYear, + style = AppTheme.typography.h6 + ) + Text( + text = stringResource( + R.string.pkv_invoices_total_of_year, + totalSumOfInvoices, + currency + ), + style = AppTheme.typography.subtitle2, + color = AppTheme.colors.primary600 + ) + } +} + +@Composable +fun LazyItemScope.InvoicesEmptyScreen() { + Column( + modifier = Modifier + .fillParentMaxSize() + .padding(PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painterResource(R.drawable.girl_red_oh_no), + contentDescription = null + ) + Text( + stringResource(R.string.invoices_no_invoices), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier.offset(y = -(PaddingDefaults.Large)) + ) + } +} + +@Composable +fun ConnectBottomBar(onClickConnect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = AppTheme.colors.primary100 + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.invoices_connect_info), + modifier = Modifier + .padding(PaddingDefaults.Medium) + .weight(1f), + style = AppTheme.typography.body2 + ) + Button( + onClick = onClickConnect, + modifier = Modifier.padding(end = PaddingDefaults.Medium) + ) { + Text(text = stringResource(R.string.invoices_connect_btn)) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/ShareInformationScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/ShareInformationScreen.kt new file mode 100644 index 00000000..93b07e4f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pkv/ui/ShareInformationScreen.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.pkv.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ImportExport +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.visualTestTag +import kotlinx.coroutines.launch + +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.SecondaryButton +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +import kotlinx.coroutines.flow.first + +@Composable +fun ShareInformationScreen( + taskId: String, + onBack: () -> Unit, + invoicesController: InvoicesController +) { + val scaffoldState = rememberScaffoldState() + + val scope = rememberCoroutineScope() + val context = LocalContext.current + var innerHeight by remember { mutableStateOf(0) } + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount, 0) + } + + AnimatedElevationScaffold( + modifier = Modifier + .imePadding() + .visualTestTag(TestTag.Profile.InvoicesDetailScreen), + topBarTitle = "", + navigationMode = NavigationBarMode.Close, + scaffoldState = scaffoldState, + bottomBar = { + ShareInformationBottomBar { + scope.launch { + invoicesController.detailState(taskId).first()?.let { + invoicesController.shareInvoicePDF(context, it) + onBack() + } + } + } + }, + listState = listState, + actions = {}, + onBack = onBack + ) { innerPadding -> + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + .onGloballyPositioned { + innerHeight = it.boundsInWindow().size.height.toInt() + }, + horizontalAlignment = Alignment.CenterHorizontally, + state = listState + ) { + item { + Image( + painterResource(R.drawable.share_sheet), + null + ) + } + item { + InformationHeader() + } + item { + InformationLabel(Icons.Rounded.ImportExport, stringResource(R.string.share_information_app_share_info)) + } + item { + Text(stringResource(R.string.share_information_or).uppercase(), style = AppTheme.typography.subtitle2l) + } + item { + InformationLabel(Icons.Rounded.SaveAlt, stringResource(R.string.share_information_app_save_info)) + } + item { + SpacerLarge() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(350.dp) + .background( + brush = Brush.verticalGradient( + startY = 40f, + endY = 450f, + colors = listOf( + AppTheme.colors.neutral000, + Color.Transparent + ) + ) + ) + ) {} + } + } +} + +@Composable +fun InformationLabel(icon: ImageVector, info: String) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = AppTheme.colors.neutral600, + modifier = Modifier.padding(end = PaddingDefaults.Medium) + ) + Text( + modifier = Modifier + .weight(1f) + .padding(vertical = PaddingDefaults.Medium), + text = info, + style = AppTheme.typography.body2 + ) + } +} + +@Composable +fun InformationHeader() { + Text( + text = stringResource(R.string.share_invoice_information_header), + modifier = Modifier + .padding(top = PaddingDefaults.XXLarge), + style = AppTheme.typography.subtitle1 + ) + SpacerSmall() +} + +@Composable +fun ShareInformationBottomBar(onClickShare: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + SecondaryButton( + onClick = onClickShare, + modifier = Modifier.padding(end = PaddingDefaults.Medium), + contentPadding = PaddingValues(horizontal = PaddingDefaults.XXLarge, vertical = 13.dp) + ) { + Text(text = stringResource(R.string.invoice_share_okay)) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt index 2c01e38d..f0bf8f32 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt @@ -40,7 +40,7 @@ interface DeletePrescriptionsBridge { @Stable class DeletePrescriptions( - private val bridge: DeletePrescriptionsBridge, + private val prescriptionDetailsController: DeletePrescriptionsBridge, private val authenticator: Authenticator ) { sealed interface State : PrescriptionServiceState { @@ -60,7 +60,7 @@ class DeletePrescriptions( private fun deletePrescriptionFlow(profileId: ProfileIdentifier, taskId: String) = flow { - emit(bridge.deletePrescription(profileId = profileId, taskId = taskId)) + emit(prescriptionDetailsController.deletePrescription(profileId = profileId, taskId = taskId)) }.map { result -> result.fold( onSuccess = { diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt index 438aec7b..4dc5ca38 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt @@ -111,7 +111,6 @@ import de.gematik.ti.erp.app.utils.compose.handleIntent import de.gematik.ti.erp.app.utils.compose.provideEmailIntent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.kodein.di.compose.rememberViewModel const val MissingValue = "---" @@ -120,10 +119,10 @@ fun PrescriptionDetailsScreen( taskId: String, mainNavController: NavController ) { - val viewModel: PrescriptionDetailsViewModel by rememberViewModel() + val prescriptionDetailsController = rememberPrescriptionDetailsController() val prescription by produceState(null) { - viewModel.screenState(taskId).collect { + prescriptionDetailsController.prescriptionDetailsFlow(taskId).collect { value = it } } @@ -148,7 +147,7 @@ fun PrescriptionDetailsScreen( composable(PrescriptionDetailsNavigationScreens.Overview.route) { PrescriptionDetailsWithScaffold( prescription = pres, - viewModel = viewModel, + prescriptionDetailsController = prescriptionDetailsController, navController = navController, onClickMedication = { selectedMedication = it @@ -230,7 +229,7 @@ fun PrescriptionDetailsScreen( @Composable private fun PrescriptionDetailsWithScaffold( prescription: PrescriptionData.Prescription, - viewModel: PrescriptionDetailsViewModel, + prescriptionDetailsController: PrescriptionDetailsController, navController: NavHostController, onClickMedication: (PrescriptionData.Medication) -> Unit, onBack: () -> Unit @@ -291,7 +290,7 @@ private fun PrescriptionDetailsWithScaffold( val authenticator = LocalAuthenticator.current val deletePrescriptionsHandle = remember { DeletePrescriptions( - bridge = viewModel, + prescriptionDetailsController = prescriptionDetailsController, authenticator = authenticator ) } @@ -337,7 +336,12 @@ private fun PrescriptionDetailsWithScaffold( listState = listState, prescription = prescription, onSwitchRedeemed = { - viewModel.redeemScannedTask(taskId = prescription.taskId, redeem = it) + coroutineScope.launch { + prescriptionDetailsController.redeemScannedTask( + taskId = prescription.taskId, + redeem = it + ) + } }, onShowInfo = { infoBottomSheetContent = it diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsController.kt similarity index 62% rename from android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsController.kt index cd0422a8..e774d03d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsController.kt @@ -18,29 +18,35 @@ package de.gematik.ti.erp.app.prescription.detail.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import de.gematik.ti.erp.app.DispatchProvider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberInstance -class PrescriptionDetailsViewModel( - val prescriptionUseCase: PrescriptionUseCase, - private val dispatchers: DispatchProvider -) : ViewModel(), DeletePrescriptionsBridge { - - suspend fun screenState(taskId: String): Flow = +@Stable +class PrescriptionDetailsController( + val prescriptionUseCase: PrescriptionUseCase +) : DeletePrescriptionsBridge { + suspend fun prescriptionDetailsFlow(taskId: String): Flow = prescriptionUseCase.generatePrescriptionDetails(taskId) - fun redeemScannedTask(taskId: String, redeem: Boolean) { - viewModelScope.launch(dispatchers.IO) { - prescriptionUseCase.redeemScannedTask(taskId, redeem) - } + suspend fun redeemScannedTask(taskId: String, redeem: Boolean) { + prescriptionUseCase.redeemScannedTask(taskId, redeem) } override suspend fun deletePrescription(profileId: ProfileIdentifier, taskId: String): Result = prescriptionUseCase.deletePrescription(profileId = profileId, taskId = taskId) } + +@Composable +fun rememberPrescriptionDetailsController(): PrescriptionDetailsController { + val prescriptionUseCase by rememberInstance() + + return remember { + PrescriptionDetailsController(prescriptionUseCase) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt index cf73606b..79bf2852 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt @@ -509,11 +509,13 @@ fun PerformerLabel(performer: String) { } @Composable -fun HandedOverLabel(whenHandedOver: Instant) { - Label( - text = remember { dateTimeMediumText(whenHandedOver) }, - label = stringResource(id = R.string.pres_detail_medication_label_handed_over) - ) +fun HandedOverLabel(whenHandedOver: FhirTemporal?) { + whenHandedOver?.let { + Label( + text = it.formattedString(), + label = stringResource(id = R.string.pres_detail_medication_label_handed_over) + ) + } } @Composable diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionController.kt similarity index 67% rename from android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionController.kt index 67e755a9..7ad76e49 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionController.kt @@ -18,10 +18,11 @@ package de.gematik.ti.erp.app.prescription.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.prescription.ui.model.ScanScreenData +import de.gematik.ti.erp.app.prescription.ui.model.ScanData import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,12 +38,12 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toCollection import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch import kotlinx.datetime.Clock +import org.kodein.di.compose.rememberInstance private data class ScanWorkflow( - val info: ScanScreenData.Info? = null, - val state: ScanScreenData.ScanState? = null, + val info: ScanData.Info? = null, + val state: ScanData.ScanState? = null, val code: ScannedCode, val coordinates: FloatArray ) { @@ -70,23 +71,23 @@ private data class ScanWorkflow( } @OptIn(ExperimentalCoroutinesApi::class) -class ScanPrescriptionViewModel( +class ScanPrescriptionController( private val prescriptionUseCase: PrescriptionUseCase, private val profilesUseCase: ProfilesUseCase, val scanner: TwoDCodeScanner, val processor: TwoDCodeProcessor, private val validator: TwoDCodeValidator, - private val dispatchers: DispatchProvider -) : ViewModel() { + dispatchers: DispatchProvider +) { private val scannedCodes = MutableStateFlow(listOf()) - var vibration = MutableSharedFlow() + var vibration = MutableSharedFlow() private set private val emptyScanWorkflow = ScanWorkflow( code = ScannedCode("", Clock.System.now()), coordinates = FloatArray(0), - state = ScanScreenData.ScanState.Final + state = ScanData.ScanState.Final ) private var existingTaskIds = scannedCodes.map { @@ -97,16 +98,20 @@ class ScanPrescriptionViewModel( } + prescriptionUseCase.getAllTasksWithTaskIdOnly().first() } - fun screenState() = scannedCodes.map { codes -> + private val stateFlow = scannedCodes.map { codes -> val totalNrOfPrescriptions = codes.sumOf { it.urls.size } val totalNrOfCodes = codes.size - ScanScreenData.State( - snackBar = ScanScreenData.ActionBar(totalNrOfPrescriptions, totalNrOfCodes) + ScanData.State( + snackBar = ScanData.ActionBar(totalNrOfPrescriptions, totalNrOfCodes) ) } - fun scanOverlayState() = flow { + val state + @Composable + get() = stateFlow.collectAsState(ScanData.defaultState) + + private val scanOverlayFlow = flow { val batchFlow = scanner.batch.mapNotNull { batch -> processor.process(batch)?.let { result -> val (json, coords) = result @@ -141,51 +146,55 @@ class ScanPrescriptionViewModel( emit(emptyScanWorkflow) return@transformLatest } - val state = it.copy(state = ScanScreenData.ScanState.Hold) + val state = it.copy(state = ScanData.ScanState.Hold) emit(state) // hold delay(1000L) - emit(it.copy(state = ScanScreenData.ScanState.Save)) // saved + emit(it.copy(state = ScanData.ScanState.Save)) // saved delay(3000L) - emit(it.copy(state = ScanScreenData.ScanState.Final)) // final + emit(it.copy(state = ScanData.ScanState.Final)) // final }.map { - if (it.state == ScanScreenData.ScanState.Hold) { - vibration.emit(ScanScreenData.VibrationPattern.Focused) + if (it.state == ScanData.ScanState.Hold) { + vibration.emit(ScanData.VibrationPattern.Focused) } - if (it.state == ScanScreenData.ScanState.Save) { + if (it.state == ScanData.ScanState.Save) { val validCode = validateScannedCode(it.code) if (validCode == null) { - vibration.emit(ScanScreenData.VibrationPattern.Error) + vibration.emit(ScanData.VibrationPattern.Error) it.copy( - info = ScanScreenData.Info.ErrorNotValid, - state = ScanScreenData.ScanState.Error + info = ScanData.Info.ErrorNotValid, + state = ScanData.ScanState.Error ) } else if (!addScannedCode(validCode)) { - vibration.emit(ScanScreenData.VibrationPattern.Error) + vibration.emit(ScanData.VibrationPattern.Error) it.copy( - info = ScanScreenData.Info.ErrorDuplicated, - state = ScanScreenData.ScanState.Error + info = ScanData.Info.ErrorDuplicated, + state = ScanData.ScanState.Error ) } else { - vibration.emit(ScanScreenData.VibrationPattern.Saved) - it.copy(info = ScanScreenData.Info.Scanned(validCode.urls.size)) + vibration.emit(ScanData.VibrationPattern.Saved) + it.copy(info = ScanData.Info.Scanned(validCode.urls.size)) } } else { it } }.collect { emit( - ScanScreenData.OverlayState( + ScanData.OverlayState( area = if (it != emptyScanWorkflow) it.coordinates else null, - state = it.state ?: ScanScreenData.ScanState.Hold, - info = it.info ?: ScanScreenData.Info.Focus + state = it.state ?: ScanData.ScanState.Hold, + info = it.info ?: ScanData.Info.Focus ) ) } }.flowOn(dispatchers.Default) + val overlayState + @Composable + get() = scanOverlayFlow.collectAsState(ScanData.defaultOverlayState) + private fun validateScannedCode(scannedCode: ScannedCode): ValidScannedCode? = validator.validate(scannedCode) @@ -205,12 +214,24 @@ class ScanPrescriptionViewModel( } } - fun saveToDatabase() { - viewModelScope.launch { - prescriptionUseCase.saveScannedCodes( - profilesUseCase.activeProfile.first().id, - scannedCodes.value - ) - } + suspend fun saveToDatabase() { + prescriptionUseCase.saveScannedCodes( + profilesUseCase.activeProfile.first().id, + scannedCodes.value + ) + } +} + +@Composable +fun rememberScanPrescriptionController(): ScanPrescriptionController { + val prescriptionUseCase by rememberInstance() + val profilesUseCase by rememberInstance() + val scanner by rememberInstance() + val processor by rememberInstance() + val validator by rememberInstance() + val dispatchers by rememberInstance() + + return remember { + ScanPrescriptionController(prescriptionUseCase, profilesUseCase, scanner, processor, validator, dispatchers) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt index 5fcb8d0a..46ee6221 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt @@ -91,7 +91,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -120,14 +119,13 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.core.LocalAnalytics import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens -import de.gematik.ti.erp.app.prescription.ui.model.ScanScreenData +import de.gematik.ti.erp.app.prescription.ui.model.ScanData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AlertDialog @@ -147,7 +145,7 @@ import java.util.concurrent.Executors @Composable fun ScanScreen( mainNavController: NavController, - scanViewModel: ScanPrescriptionViewModel + scanPrescriptionController: ScanPrescriptionController ) { val context = LocalContext.current @@ -168,7 +166,7 @@ fun ScanScreen( var flashEnabled by remember { mutableStateOf(false) } - val state by scanViewModel.screenState().collectAsState(ScanScreenData.defaultScreenState) + val state by scanPrescriptionController.state val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) val coroutineScope = rememberCoroutineScope() @@ -193,7 +191,9 @@ fun ScanScreen( sheetContent = { SheetContent( onClickSave = { - scanViewModel.saveToDatabase() + coroutineScope.launch { + scanPrescriptionController.saveToDatabase() + } tracker.trackSaveScannedPrescriptions() mainNavController.navigate(MainNavigationScreens.Prescriptions.path()) } @@ -203,7 +203,7 @@ fun ScanScreen( Box { if (camPermissionGranted) { CameraView( - scanViewModel, + scanPrescriptionController, Modifier.fillMaxSize(), flashEnabled = flashEnabled, onFlashToggled = { @@ -219,7 +219,8 @@ fun ScanScreen( flashEnabled = flashEnabled, onFlashClick = { flashEnabled = it }, modifier = Modifier.fillMaxSize(), - navController = mainNavController + navController = mainNavController, + scanPrescriptionController ) if (state.snackBar.shouldShow()) { @@ -238,7 +239,7 @@ fun ScanScreen( } } - HapticAndAudibleFeedback() + HapticAndAudibleFeedback(scanPrescriptionController) } @Composable @@ -335,7 +336,7 @@ private fun AccessDenied(navController: NavController) { } @Composable -private fun HapticAndAudibleFeedback(scanVM: ScanPrescriptionViewModel = viewModel()) { +private fun HapticAndAudibleFeedback(scanPrescriptionController: ScanPrescriptionController) { val context = LocalContext.current val toneGenerator = remember { @@ -346,8 +347,8 @@ private fun HapticAndAudibleFeedback(scanVM: ScanPrescriptionViewModel = viewMod } } - LaunchedEffect(scanVM.vibration) { - scanVM.vibration.collect { + LaunchedEffect(scanPrescriptionController.vibration) { + scanPrescriptionController.vibration.collect { val vibrator = if (VERSION.SDK_INT >= VERSION_CODES.S) { val vibratorManager = context.getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator @@ -389,14 +390,14 @@ private fun SaveDialog( } ) -private fun beep(toneGenerator: ToneGenerator, pattern: ScanScreenData.VibrationPattern) { +private fun beep(toneGenerator: ToneGenerator, pattern: ScanData.VibrationPattern) { @Suppress("NON_EXHAUSTIVE_WHEN") when (pattern) { - ScanScreenData.VibrationPattern.Saved -> toneGenerator.startTone( + ScanData.VibrationPattern.Saved -> toneGenerator.startTone( ToneGenerator.TONE_PROP_PROMPT, 1000 ) - ScanScreenData.VibrationPattern.Error -> toneGenerator.startTone( + ScanData.VibrationPattern.Error -> toneGenerator.startTone( ToneGenerator.TONE_PROP_NACK, 1000 ) @@ -404,8 +405,8 @@ private fun beep(toneGenerator: ToneGenerator, pattern: ScanScreenData.Vibration } } -private fun vibrate(vibrator: Vibrator, pattern: ScanScreenData.VibrationPattern) { - if (pattern == ScanScreenData.VibrationPattern.None) { +private fun vibrate(vibrator: Vibrator, pattern: ScanData.VibrationPattern) { + if (pattern == ScanData.VibrationPattern.None) { return } @@ -414,11 +415,11 @@ private fun vibrate(vibrator: Vibrator, pattern: ScanScreenData.VibrationPattern if (VERSION.SDK_INT >= VERSION_CODES.O) { vibrator.vibrate( when (pattern) { - ScanScreenData.VibrationPattern.Focused -> + ScanData.VibrationPattern.Focused -> createOneShot(100L, 100) - ScanScreenData.VibrationPattern.Saved -> + ScanData.VibrationPattern.Saved -> createOneShot(300L, 100) - ScanScreenData.VibrationPattern.Error -> + ScanData.VibrationPattern.Error -> createWaveform( longArrayOf(100, 100, 300), intArrayOf( @@ -428,19 +429,19 @@ private fun vibrate(vibrator: Vibrator, pattern: ScanScreenData.VibrationPattern ), -1 ) - ScanScreenData.VibrationPattern.None -> error("Should not be reached") + ScanData.VibrationPattern.None -> error("Should not be reached") } ) } else { @Suppress("DEPRECATION") when (pattern) { - ScanScreenData.VibrationPattern.Focused -> + ScanData.VibrationPattern.Focused -> vibrator.vibrate(longArrayOf(0L, 100L), -1) - ScanScreenData.VibrationPattern.Saved -> + ScanData.VibrationPattern.Saved -> vibrator.vibrate(longArrayOf(0L, 300L), -1) - ScanScreenData.VibrationPattern.Error -> + ScanData.VibrationPattern.Error -> vibrator.vibrate(longArrayOf(0L, 100L, 100L, 300L), -1) - ScanScreenData.VibrationPattern.None -> error("Should not be reached") + ScanData.VibrationPattern.None -> error("Should not be reached") } } } @@ -448,7 +449,7 @@ private fun vibrate(vibrator: Vibrator, pattern: ScanScreenData.VibrationPattern @Composable private fun ActionBarButton( - data: ScanScreenData.ActionBar, + data: ScanData.ActionBar, onClick: () -> Unit, modifier: Modifier = Modifier ) = @@ -475,7 +476,7 @@ private fun ActionBarButton( @Composable private fun InfoCard( - info: ScanScreenData.Info, + info: ScanData.Info, modifier: Modifier ) = Card( @@ -493,7 +494,7 @@ private fun InfoCard( val scanning = stringResource(R.string.cam_info_scanning) val invalid = stringResource(R.string.cam_info_invalid) val duplicated = stringResource(R.string.cam_info_duplicated) - val detected = if (info is ScanScreenData.Info.Scanned) { + val detected = if (info is ScanData.Info.Scanned) { annotatedPluralsResource( R.plurals.cam_info_detected, info.nr, @@ -504,14 +505,14 @@ private fun InfoCard( } when (info) { - ScanScreenData.Info.Focus -> Text( + ScanData.Info.Focus -> Text( scanning, textAlign = TextAlign.Center, style = AppTheme.typography.subtitle1 ) - ScanScreenData.Info.ErrorNotValid -> InfoError(invalid) - ScanScreenData.Info.ErrorDuplicated -> InfoError(duplicated) - is ScanScreenData.Info.Scanned -> Text( + ScanData.Info.ErrorNotValid -> InfoError(invalid) + ScanData.Info.ErrorDuplicated -> InfoError(duplicated) + is ScanData.Info.Scanned -> Text( detected, textAlign = TextAlign.Center, style = AppTheme.typography.subtitle1 @@ -519,10 +520,10 @@ private fun InfoCard( } val acc = when (info) { - ScanScreenData.Info.Focus -> scanning - ScanScreenData.Info.ErrorNotValid -> invalid - ScanScreenData.Info.ErrorDuplicated -> duplicated - is ScanScreenData.Info.Scanned -> detected.text + ScanData.Info.Focus -> scanning + ScanData.Info.ErrorNotValid -> invalid + ScanData.Info.ErrorDuplicated -> duplicated + is ScanData.Info.Scanned -> detected.text } DisposableEffect(view, acc) { @@ -550,7 +551,7 @@ private fun InfoError(text: String) { @SuppressLint("UnsafeOptInUsageError") @Composable private fun CameraView( - scanVM: ScanPrescriptionViewModel = viewModel(), + scanPrescriptionController: ScanPrescriptionController, modifier: Modifier, flashEnabled: Boolean, onFlashToggled: (Boolean) -> Unit @@ -593,7 +594,7 @@ private fun CameraView( .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .apply { - setAnalyzer(Executors.newSingleThreadExecutor(), scanVM.scanner) + setAnalyzer(Executors.newSingleThreadExecutor(), scanPrescriptionController.scanner) } preview.setSurfaceProvider(camPreviewView.surfaceProvider) @@ -632,7 +633,7 @@ private fun CameraView( } camPreviewView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - scanVM.processor.onLayoutChange( + scanPrescriptionController.processor.onLayoutChange( Size( camPreviewView.width, camPreviewView.height @@ -698,46 +699,41 @@ private fun ScanOverlay( onFlashClick: (Boolean) -> Unit, modifier: Modifier = Modifier, navController: NavController, - scanVM: ScanPrescriptionViewModel = viewModel() + scanPrescriptionController: ScanPrescriptionController ) { var points by remember { mutableStateOf(FloatArray(8)) } - var state by remember { mutableStateOf(ScanScreenData.defaultOverlayState) } + val overlayState by scanPrescriptionController.overlayState LaunchedEffect(enabled) { if (enabled) { - scanVM.scanOverlayState().collect { - state = it - it.area?.let { - points = it - } + overlayState.area?.let { + points = it } - } else { - state = ScanScreenData.defaultOverlayState } } val fillColor = - when (state.state) { - ScanScreenData.ScanState.Hold -> AppTheme.colors.scanOverlayHoldFill - ScanScreenData.ScanState.Save -> AppTheme.colors.scanOverlaySavedFill - ScanScreenData.ScanState.Error -> AppTheme.colors.scanOverlayErrorFill - ScanScreenData.ScanState.Final -> AppTheme.colors.scanOverlayHoldFill + when (overlayState.state) { + ScanData.ScanState.Hold -> AppTheme.colors.scanOverlayHoldFill + ScanData.ScanState.Save -> AppTheme.colors.scanOverlaySavedFill + ScanData.ScanState.Error -> AppTheme.colors.scanOverlayErrorFill + ScanData.ScanState.Final -> AppTheme.colors.scanOverlayHoldFill } val outlineColor = - when (state.state) { - ScanScreenData.ScanState.Hold -> AppTheme.colors.scanOverlayHoldOutline - ScanScreenData.ScanState.Save -> AppTheme.colors.scanOverlaySavedOutline - ScanScreenData.ScanState.Error -> AppTheme.colors.scanOverlayErrorOutline - ScanScreenData.ScanState.Final -> AppTheme.colors.scanOverlayHoldOutline + when (overlayState.state) { + ScanData.ScanState.Hold -> AppTheme.colors.scanOverlayHoldOutline + ScanData.ScanState.Save -> AppTheme.colors.scanOverlaySavedOutline + ScanData.ScanState.Error -> AppTheme.colors.scanOverlayErrorOutline + ScanData.ScanState.Final -> AppTheme.colors.scanOverlayHoldOutline } - val showAimAid = state.area == null && enabled + val showAimAid = overlayState.area == null && enabled Box(modifier = modifier) { Canvas(modifier = Modifier.fillMaxSize()) { - if (state.area != null) { + if (overlayState.area != null) { val p = Path().apply { moveTo(points[0], points[1]) lineTo(points[2], points[3]) @@ -772,7 +768,7 @@ private fun ScanOverlay( ) Spacer(modifier = Modifier.size(24.dp)) InfoCard( - state.info, + overlayState.info, modifier = Modifier.align(Alignment.CenterHorizontally) ) if (showAimAid) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanData.kt similarity index 97% rename from android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt rename to android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanData.kt index c0edd5ce..edd0c9b1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanData.kt @@ -20,7 +20,7 @@ package de.gematik.ti.erp.app.prescription.ui.model import androidx.compose.runtime.Immutable -object ScanScreenData { +object ScanData { enum class ScanState { Hold, Save, @@ -88,7 +88,7 @@ object ScanScreenData { snackBar.shouldShow() } - val defaultScreenState = State( + val defaultState = State( snackBar = ActionBar(0, 0) ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt index e00d00be..f43fa8fe 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt @@ -23,18 +23,23 @@ import TokenScreen import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavController import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import de.gematik.ti.erp.app.Route import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens import de.gematik.ti.erp.app.mainscreen.ui.MainScreenController +import de.gematik.ti.erp.app.pkv.ui.InvoiceDetailsScreen import de.gematik.ti.erp.app.pkv.ui.InvoiceInformationScreen +import de.gematik.ti.erp.app.pkv.ui.InvoicesScreen +import de.gematik.ti.erp.app.pkv.ui.ShareInformationScreen +import de.gematik.ti.erp.app.pkv.ui.rememberInvoicesController import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.settings.ui.SettingsController -import de.gematik.ti.erp.app.settings.ui.SettingStatesData import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.NavigationMode import kotlinx.coroutines.launch @@ -46,22 +51,47 @@ object ProfileDestinations { object PairedDevices : Route("pairedDevices") object ProfileImagePicker : Route("profileImagePicker") object ProfileImageCropper : Route("imageCropper") - object InvoiceInformation : Route("invoiceInformation") + object Invoices : Route("invoices") + + object InvoiceInformation : + Route( + "invoiceInformation", + navArgument("taskId") { type = NavType.StringType } + ) { + fun path(taskId: String) = path("taskId" to taskId) + } + + object InvoiceDetails : + Route( + "invoiceDetails", + navArgument("taskId") { type = NavType.StringType } + ) { + fun path(taskId: String) = path("taskId" to taskId) + } + + object ShareInformation : + Route( + "shareInformation", + navArgument("taskId") { type = NavType.StringType } + ) { + fun path(taskId: String) = path("taskId" to taskId) + } } +@Suppress("LongMethod") @Composable fun EditProfileNavGraph( - profilesState: SettingStatesData.ProfilesState, + profilesState: ProfilesStateData.ProfilesState, navController: NavHostController, onBack: () -> Unit, selectedProfile: ProfilesUseCaseData.Profile, - settingsController: SettingsController, mainScreenController: MainScreenController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesController: ProfilesController, onRemoveProfile: (newProfileName: String?) -> Unit, mainNavController: NavController ) { val scope = rememberCoroutineScope() + val invoicesController = rememberInvoicesController(profileId = selectedProfile.id) NavHost(navController = navController, startDestination = ProfileDestinations.Profile.route) { composable(ProfileDestinations.Profile.route) { EditProfileScreenContent( @@ -69,7 +99,7 @@ fun EditProfileNavGraph( onClickAuditEvents = { navController.navigate(ProfileDestinations.AuditEvents.path()) }, onClickLogIn = { scope.launch { - settingsController.switchProfile(selectedProfile) + profilesController.switchActiveProfile(selectedProfile) } mainNavController.navigate( MainNavigationScreens.CardWall.path(selectedProfile.id) @@ -77,20 +107,19 @@ fun EditProfileNavGraph( }, onClickLogout = { scope.launch { - settingsController.logout(selectedProfile) + profilesController.logout(selectedProfile) } }, onBack = onBack, profilesState = profilesState, - settingsController = settingsController, - profileSettingsViewModel = profileSettingsViewModel, + profilesController = profilesController, selectedProfile = selectedProfile, onRemoveProfile = onRemoveProfile, onClickEditAvatar = { navController.navigate(ProfileDestinations.ProfileImagePicker.path()) }, onClickPairedDevices = { navController.navigate(ProfileDestinations.PairedDevices.path()) }, - onClickInvoiceInformation = { navController.navigate(ProfileDestinations.InvoiceInformation.path()) } + onClickInvoices = { navController.navigate(ProfileDestinations.Invoices.path()) } ) } @@ -98,17 +127,23 @@ fun EditProfileNavGraph( ProfileColorAndImagePicker( selectedProfile, clearPersonalizedImage = { - profileSettingsViewModel.clearPersonalizedImage(selectedProfile.id) + scope.launch { + profilesController.clearPersonalizedImage(selectedProfile.id) + } }, onBack = { navController.popBackStack() }, onPickPersonalizedImage = { navController.navigate(ProfileDestinations.ProfileImageCropper.path()) }, onSelectAvatar = { avatar -> - profileSettingsViewModel.saveAvatarFigure(selectedProfile.id, avatar) + scope.launch { + profilesController.saveAvatarFigure(selectedProfile.id, avatar) + } }, onSelectProfileColor = { color -> - profileSettingsViewModel.updateProfileColor(selectedProfile, color) + scope.launch { + profilesController.updateProfileColor(selectedProfile, color) + } } ) } @@ -118,7 +153,9 @@ fun EditProfileNavGraph( ) { ProfileImageCropper( onSaveCroppedImage = { - profileSettingsViewModel.savePersonalizedProfileImage(selectedProfile.id, it) + scope.launch { + profilesController.savePersonalizedProfileImage(selectedProfile.id, it) + } navController.popBackStack() }, onBack = { @@ -128,7 +165,7 @@ fun EditProfileNavGraph( } composable(ProfileDestinations.Token.route) { - val accessToken by settingsController.decryptedAccessToken(selectedProfile).collectAsState(null) + val accessToken by profilesController.decryptedAccessToken(selectedProfile).collectAsState(null) NavigationAnimation(mode = NavigationMode.Closed) { TokenScreen( @@ -142,7 +179,7 @@ fun EditProfileNavGraph( NavigationAnimation(mode = NavigationMode.Closed) { AuditEventsScreen( profileId = selectedProfile.id, - settingsController = settingsController, + profilesController = profilesController, lastAuthenticated = selectedProfile.lastAuthenticated, tokenValid = selectedProfile.ssoTokenValid() ) { navController.popBackStack() } @@ -152,23 +189,90 @@ fun EditProfileNavGraph( NavigationAnimation(mode = NavigationMode.Closed) { PairedDevicesScreen( selectedProfile = selectedProfile, - settingsController = settingsController, + profilesController = profilesController, onBack = { navController.popBackStack() } ) } } - composable(ProfileDestinations.InvoiceInformation.route) { + composable(ProfileDestinations.Invoices.route) { NavigationAnimation(mode = NavigationMode.Closed) { - InvoiceInformationScreen( + InvoicesScreen( mainScreenController = mainScreenController, + invoicesController = invoicesController, selectedProfile = selectedProfile, + onBack = { navController.popBackStack() }, + onClickInvoice = { taskId -> + navController.navigate( + ProfileDestinations.InvoiceInformation.path(taskId) + ) + }, + onClickShare = { taskId -> + navController.navigate( + ProfileDestinations.ShareInformation.path(taskId) + ) + }, + onShowCardWall = { + mainNavController.navigate( + MainNavigationScreens.CardWall.path(selectedProfile.id) + ) + } + ) + } + } + + composable( + ProfileDestinations.InvoiceInformation.route, + ProfileDestinations.InvoiceInformation.arguments + ) { + val taskId = remember { requireNotNull(it.arguments?.getString("taskId")) } + + NavigationAnimation(mode = NavigationMode.Closed) { + InvoiceInformationScreen( + selectedProfile = selectedProfile, + taskId = taskId, + onClickShowMore = { + navController.navigate( + ProfileDestinations.InvoiceDetails.path(taskId) + ) + }, + onClickSubmit = { + navController.navigate( + ProfileDestinations.ShareInformation.path(taskId) + ) + }, onBack = { navController.popBackStack() } - ) { - mainNavController.navigate( - MainNavigationScreens.CardWall.path(selectedProfile.id) - ) - } + ) + } + } + + composable( + ProfileDestinations.InvoiceDetails.route, + ProfileDestinations.InvoiceDetails.arguments + ) { + val taskId = remember { requireNotNull(it.arguments?.getString("taskId")) } + + NavigationAnimation(mode = NavigationMode.Closed) { + InvoiceDetailsScreen( + invoicesController = invoicesController, + taskId = taskId, + onBack = { navController.popBackStack() } + ) + } + } + + composable( + ProfileDestinations.ShareInformation.route, + ProfileDestinations.ShareInformation.arguments + ) { + val taskId = remember { requireNotNull(it.arguments?.getString("taskId")) } + + NavigationAnimation(mode = NavigationMode.Closed) { + ShareInformationScreen( + invoicesController = invoicesController, + taskId = taskId, + onBack = { navController.popBackStack() } + ) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt index be7e713b..2d9b1f8b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt @@ -109,8 +109,6 @@ import de.gematik.ti.erp.app.mainscreen.ui.rememberMainScreenController import de.gematik.ti.erp.app.profiles.model.ProfilesData import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.settings.ui.ProfileNameDialog -import de.gematik.ti.erp.app.settings.ui.SettingsController -import de.gematik.ti.erp.app.settings.ui.SettingStatesData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold @@ -128,10 +126,9 @@ import kotlinx.datetime.Instant @Composable fun EditProfileScreen( - profilesState: SettingStatesData.ProfilesState, + profilesState: ProfilesStateData.ProfilesState, profile: ProfilesUseCaseData.Profile, - settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesController: ProfilesController, onRemoveProfile: (newProfileName: String?) -> Unit, onBack: () -> Unit, mainNavController: NavController @@ -144,9 +141,8 @@ fun EditProfileScreen( navController = navController, onBack = onBack, selectedProfile = profile, - settingsController = settingsController, mainScreenController = mainScreenController, - profileSettingsViewModel = profileSettingsViewModel, + profilesController = profilesController, onRemoveProfile = onRemoveProfile, mainNavController = mainNavController ) @@ -155,12 +151,11 @@ fun EditProfileScreen( @Composable fun EditProfileScreen( profileId: String, - settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesController: ProfilesController, onBack: () -> Unit, mainNavController: NavController ) { - val profilesState by settingsController.profilesState + val profilesState by profilesController.profilesState val scope = rememberCoroutineScope() profilesState.profileById(profileId)?.let { profile -> @@ -171,11 +166,10 @@ fun EditProfileScreen( profilesState = profilesState, onBack = onBack, profile = selectedProfile, - settingsController = settingsController, - profileSettingsViewModel = profileSettingsViewModel, + profilesController = profilesController, onRemoveProfile = { scope.launch { - settingsController.removeProfile(profile, it) + profilesController.removeProfile(profile, it) onBack() } }, @@ -189,9 +183,8 @@ fun EditProfileScreen( fun EditProfileScreenContent( onBack: () -> Unit, selectedProfile: ProfilesUseCaseData.Profile, - profilesState: SettingStatesData.ProfilesState, - settingsController: SettingsController, - profileSettingsViewModel: ProfileSettingsViewModel, + profilesState: ProfilesStateData.ProfilesState, + profilesController: ProfilesController, onRemoveProfile: (newProfileName: String?) -> Unit, onClickEditAvatar: () -> Unit, onClickToken: () -> Unit, @@ -199,16 +192,16 @@ fun EditProfileScreenContent( onClickLogout: () -> Unit, onClickAuditEvents: () -> Unit, onClickPairedDevices: () -> Unit, - onClickInvoiceInformation: () -> Unit + onClickInvoices: () -> Unit ) { val listState = rememberLazyListState() val scaffoldState = rememberScaffoldState() - + val scope = rememberCoroutineScope() var showAddDefaultProfileDialog by remember { mutableStateOf(false) } var deleteProfileDialogVisible by remember { mutableStateOf(false) } if (deleteProfileDialogVisible) { - deleteProfileDialog( + DeleteProfileDialog( onCancel = { deleteProfileDialogVisible = false }, onClickAction = { if (profilesState.profiles.size == 1) { @@ -249,7 +242,9 @@ fun EditProfileScreenContent( profile = selectedProfile, profilesState = profilesState, onChangeProfileName = { - profileSettingsViewModel.updateProfileName(selectedProfile.id, it) + scope.launch { + profilesController.updateProfileName(selectedProfile.id, it) + } } ) } @@ -271,7 +266,7 @@ fun EditProfileScreenContent( if (selectedProfile.insuranceInformation.insuranceType == ProfilesUseCaseData.InsuranceType.PKV) { item { - ProfileInvoiceInformation { onClickInvoiceInformation() } + ProfileInvoiceInformation { onClickInvoices() } } } @@ -285,7 +280,7 @@ fun EditProfileScreenContent( if (showAddDefaultProfileDialog) { ProfileNameDialog( - settingsController = settingsController, + profilesController = profilesController, wantRemoveLastProfile = true, onEdit = { showAddDefaultProfileDialog = false; onRemoveProfile(it) }, onDismissRequest = { showAddDefaultProfileDialog = false } @@ -389,7 +384,7 @@ fun ThreeDotMenu( } @Composable -fun deleteProfileDialog(onCancel: () -> Unit, onClickAction: () -> Unit) { +fun DeleteProfileDialog(onCancel: () -> Unit, onClickAction: () -> Unit) { CommonAlertDialog( header = stringResource(id = R.string.remove_profile_header), info = stringResource(R.string.remove_profile_detail_message), @@ -489,7 +484,7 @@ fun SettingsMenuHeadline(text: String) { @Composable fun ProfileNameSection( profile: ProfilesUseCaseData.Profile, - profilesState: SettingStatesData.ProfilesState, + profilesState: ProfilesStateData.ProfilesState, onChangeProfileName: (String) -> Unit ) { var profileName by remember(profile.name) { mutableStateOf(profile.name) } @@ -588,7 +583,7 @@ fun ProfileEditBasicTextField( textStyle: TextStyle = AppTheme.typography.h5, initialProfileName: String, onChangeProfileName: (String, Boolean) -> Unit, - profilesState: SettingStatesData.ProfilesState, + profilesState: ProfilesStateData.ProfilesState, onDone: () -> Unit ) { var profileNameState by remember { @@ -681,14 +676,14 @@ fun ProfileAvatarSection( @Composable fun ChooseAvatar( + iconModifier: Modifier = Modifier, useSmallImages: Boolean? = false, profile: ProfilesUseCaseData.Profile, - iconModifier: Modifier = Modifier, emptyIcon: ImageVector, showPersonalizedImage: Boolean = true, figure: ProfilesData.AvatarFigure ) { - val imageRessource = ExtractImageResource(useSmallImages, figure) + val imageRessource = extractImageResource(useSmallImages, figure) when (figure) { ProfilesData.AvatarFigure.PersonalizedImage -> { @@ -726,7 +721,7 @@ fun ChooseAvatar( } @Composable -private fun ExtractImageResource( +private fun extractImageResource( useSmallImages: Boolean? = false, figure: ProfilesData.AvatarFigure ) = if (useSmallImages == true) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt index 14e18e44..7e9bde0b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt @@ -65,7 +65,6 @@ import de.gematik.ti.erp.app.core.LocalAuthenticator import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.usecase.RefreshFlowException import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.settings.ui.SettingsController import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold @@ -123,7 +122,7 @@ fun ProfileEditPairedDeviceSection( @Composable fun PairedDevicesScreen( selectedProfile: ProfilesUseCaseData.Profile, - settingsController: SettingsController, + profilesController: ProfilesController, onBack: () -> Unit ) { val listState = rememberLazyListState() @@ -137,7 +136,7 @@ fun PairedDevicesScreen( PairedDevices( modifier = Modifier.padding(it), selectedProfile = selectedProfile, - settingsController = settingsController, + profilesController = profilesController, listState = listState ) } @@ -175,7 +174,7 @@ private sealed interface DeleteState { private fun PairedDevices( modifier: Modifier, selectedProfile: ProfilesUseCaseData.Profile, - settingsController: SettingsController, + profilesController: ProfilesController, listState: LazyListState ) { val authenticator = LocalAuthenticator.current @@ -187,7 +186,7 @@ private fun PairedDevices( .onStart { emit(Unit) } // emit once to start the flow directly .collectLatest { state = RefreshState.Loading - settingsController + profilesController .pairedDevices(selectedProfile.id) .retry(1) { throwable -> Napier.e("Couldn't get paired devices", throwable) @@ -244,7 +243,7 @@ private fun PairedDevices( onClickAction = { coroutineScope.launch { mutex.mutate { - settingsController + profilesController .deletePairedDevice(selectedProfile.id, it.device) .onFailure { deleteState = DeleteState.Error diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileController.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileController.kt new file mode 100644 index 00000000..c3a035f4 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileController.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.profiles.ui + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.paging.PagingData +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.ProfileAvatarUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesWithPairedDevicesUseCase +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.protocol.model.AuditEventData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.kodein.di.compose.rememberInstance + +class ProfilesController( + private val profilesUseCase: ProfilesUseCase, + private val profilesWithPairedDevicesUseCase: ProfilesWithPairedDevicesUseCase, + private val profileAvatarUseCase: ProfileAvatarUseCase +) : ProfileBridge { + private val profilesFlow = profilesUseCase.profiles.map { ProfilesStateData.ProfilesState(it) } + + val profilesState + @Composable + get() = profilesFlow.collectAsState(ProfilesStateData.defaultProfilesState) + + fun pairedDevices(profileId: ProfileIdentifier) = + profilesWithPairedDevicesUseCase.pairedDevices(profileId) + + // tag::DeletePairedDevicesViewModel[] + suspend fun deletePairedDevice(profileId: ProfileIdentifier, device: ProfilesUseCaseData.PairedDevice) = + profilesWithPairedDevicesUseCase.deletePairedDevices(profileId, device) + + // end::DeletePairedDevicesViewModel[] + fun decryptedAccessToken(profile: ProfilesUseCaseData.Profile) = + profilesUseCase.decryptedAccessToken(profile.id) + + suspend fun logout(profile: ProfilesUseCaseData.Profile) { + profilesUseCase.logout(profile) + } + + suspend fun addProfile(profileName: String) { + profilesUseCase.addProfile(profileName, activate = true) + } + + suspend fun removeProfile(profile: ProfilesUseCaseData.Profile, newProfileName: String?) { + if (newProfileName != null) { + profilesUseCase.removeAndSaveProfile(profile, newProfileName) + } else { + profilesUseCase.removeProfile(profile) + } + } + + fun loadAuditEventsForProfile(profileId: ProfileIdentifier): Flow> = + profilesUseCase.auditEvents(profileId) + + override suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) { + profilesUseCase.switchActiveProfile(profile) + } + + override val profiles: Flow> = + profilesUseCase.profiles + + override suspend fun switchProfileToPKV(profile: ProfilesUseCaseData.Profile) { + profilesUseCase.switchProfileToPKV(profile) + } + + suspend fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfilesData.ProfileColorNames) { + profilesUseCase.updateProfileColor(profile, color) + } + + suspend fun savePersonalizedProfileImage(profileId: ProfileIdentifier, profileImage: Bitmap) { + profileAvatarUseCase.savePersonalizedProfileImage(profileId, profileImage) + } + + suspend fun updateProfileName(profileId: ProfileIdentifier, newName: String) { + profilesUseCase.updateProfileName(profileId, newName) + } + + suspend fun saveAvatarFigure(profileId: ProfileIdentifier, avatarFigure: ProfilesData.AvatarFigure) { + profileAvatarUseCase.saveAvatarFigure(profileId, avatarFigure) + } + + suspend fun clearPersonalizedImage(profileId: ProfileIdentifier) { + profileAvatarUseCase.clearPersonalizedImage(profileId) + } +} + +@Composable +fun rememberProfilesController(): ProfilesController { + val profilesUseCase by rememberInstance() + val profilesWithPairedDevicesUseCase by rememberInstance() + val profileAvatarUseCase by rememberInstance() + return remember { + ProfilesController( + profilesUseCase, + profilesWithPairedDevicesUseCase, + profileAvatarUseCase + ) + } +} + +object ProfilesStateData { + @Immutable + data class ProfilesState( + val profiles: List + ) { + fun activeProfile() = profiles.find { it.active }!! + fun profileById(profileId: String) = profiles.find { it.id == profileId } + fun containsProfileWithName(name: String) = profiles.any { + it.name.equals(name.trim(), true) + } + } + + val defaultProfilesState = ProfilesState( + profiles = listOf() + ) + + val defaultProfile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation( + insuranceType = ProfilesUseCaseData.InsuranceType.NONE + ), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt index 7caac7cb..850cd745 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt @@ -29,17 +29,13 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.idp.model.IdpData -import de.gematik.ti.erp.app.profiles.model.ProfilesData -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn -import org.kodein.di.compose.rememberViewModel interface ProfileBridge { val profiles: Flow> @@ -47,39 +43,11 @@ interface ProfileBridge { suspend fun switchProfileToPKV(profile: ProfilesUseCaseData.Profile) } -class ProfileViewModel( - private val profilesUseCase: ProfilesUseCase -) : ViewModel(), ProfileBridge { - override val profiles: Flow> = - profilesUseCase.profiles - - override suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) { - profilesUseCase.switchActiveProfile(profile) - } - - override suspend fun switchProfileToPKV(profile: ProfilesUseCaseData.Profile) { - profilesUseCase.switchProfileToPKV(profile) - } -} - -val DefaultProfile = ProfilesUseCaseData.Profile( - id = "", - name = "", - insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation( - insuranceType = ProfilesUseCaseData.InsuranceType.NONE - ), - active = false, - color = ProfilesData.ProfileColorNames.SPRING_GRAY, - lastAuthenticated = null, - ssoTokenScope = null, - avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage -) - @Stable class ProfileHandler( private val bridge: ProfileBridge, coroutineScope: CoroutineScope, - activeDefaultProfile: ProfilesUseCaseData.Profile = DefaultProfile + activeDefaultProfile: ProfilesUseCaseData.Profile = ProfilesStateData.defaultProfile ) { enum class ProfileConnectionState { LoggedIn, @@ -142,7 +110,7 @@ class ProfileHandler( bridge .profiles .onEach { - activeProfile = it.find { it.active } ?: DefaultProfile + activeProfile = it.find { it.active } ?: ProfilesStateData.defaultProfile } .shareIn(coroutineScope, SharingStarted.Eagerly, 1) @@ -160,28 +128,28 @@ class ProfileHandler( } private fun profileHandlerSaver( - bridge: ProfileBridge, + profilesController: ProfilesController, scope: CoroutineScope ): Saver = Saver( save = { state -> state.activeProfile.id }, restore = { savedState -> - ProfileHandler(bridge, scope, DefaultProfile.copy(id = savedState)) + ProfileHandler(profilesController, scope, ProfilesStateData.defaultProfile.copy(id = savedState)) } ) @Composable fun rememberProfileHandler(): ProfileHandler { - val profileViewModel by rememberViewModel() + val profilesController = rememberProfilesController() val coroutineScope = rememberCoroutineScope() return rememberSaveable( saver = profileHandlerSaver( - profileViewModel, + profilesController, coroutineScope ) ) { - ProfileHandler(profileViewModel, coroutineScope) + ProfileHandler(profilesController, coroutineScope) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt deleted file mode 100644 index 9e6e08c0..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2023 gematik GmbH - * - * 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 de.gematik.ti.erp.app.profiles.ui - -import android.graphics.Bitmap -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import de.gematik.ti.erp.app.profiles.model.ProfilesData -import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier -import de.gematik.ti.erp.app.profiles.usecase.ProfileAvatarUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData - -import kotlinx.coroutines.launch - -class ProfileSettingsViewModel( - private val profilesUseCase: ProfilesUseCase, - private val profileAvatarUseCase: ProfileAvatarUseCase -) : ViewModel() { - - fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfilesData.ProfileColorNames) { - viewModelScope.launch { - profilesUseCase.updateProfileColor(profile, color) - } - } - - fun savePersonalizedProfileImage(profileId: ProfileIdentifier, profileImage: Bitmap) { - viewModelScope.launch { - profileAvatarUseCase.savePersonalizedProfileImage(profileId, profileImage) - } - } - - fun updateProfileName(profileId: ProfileIdentifier, newName: String) { - viewModelScope.launch { - profilesUseCase.updateProfileName(profileId, newName) - } - } - - fun saveAvatarFigure(profileId: ProfileIdentifier, avatarFigure: ProfilesData.AvatarFigure) { - viewModelScope.launch { - profileAvatarUseCase.saveAvatarFigure(profileId, avatarFigure) - } - } - - fun clearPersonalizedImage(profileId: ProfileIdentifier) { - viewModelScope.launch { - profileAvatarUseCase.clearPersonalizedImage(profileId) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/Navigation.kt b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/Navigation.kt index 30bd6870..cab1d215 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/Navigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/Navigation.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import de.gematik.ti.erp.app.analytics.TrackNavigationChanges import de.gematik.ti.erp.app.mainscreen.ui.MainScreenController import de.gematik.ti.erp.app.pharmacy.ui.PharmacyNavigation import de.gematik.ti.erp.app.pharmacy.ui.PrescriptionSelection @@ -40,6 +41,9 @@ fun RedeemNavigation( val navController = rememberNavController() val navigationMode by navController.navigationModeState(RedeemNavigation.HowToRedeem.route) + + TrackNavigationChanges(navController) + NavHost( navController, startDestination = RedeemNavigation.HowToRedeem.route diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt index afb9d600..2b0a5c71 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.settings.ui.SettingsController +import de.gematik.ti.erp.app.settings.ui.rememberSettingsController import de.gematik.ti.erp.app.utils.compose.AcceptDialog import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.LabeledSwitch @@ -39,7 +39,8 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium import kotlinx.coroutines.launch @Composable -fun AccessibilitySettingsScreen(settingsController: SettingsController, onBack: () -> Unit) { +fun AccessibilitySettingsScreen(onBack: () -> Unit) { + val settingsController = rememberSettingsController() val zoomState by settingsController.zoomState val screenshotState by settingsController.screenShotState val scope = rememberCoroutineScope() diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt index 69241155..82504e10 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt @@ -42,7 +42,7 @@ import androidx.paging.compose.itemsIndexed import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier -import de.gematik.ti.erp.app.settings.ui.SettingsController +import de.gematik.ti.erp.app.profiles.ui.ProfilesController import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold @@ -59,13 +59,13 @@ import java.time.LocalDateTime @Composable fun AuditEventsScreen( profileId: ProfileIdentifier, - settingsController: SettingsController, + profilesController: ProfilesController, lastAuthenticated: Instant?, tokenValid: Boolean, onBack: () -> Unit ) { val header = stringResource(R.string.autitEvents_headline) - val auditEventPagingFlow = remember(profileId) { settingsController.loadAuditEventsForProfile(profileId) } + val auditEventPagingFlow = remember(profileId) { profilesController.loadAuditEventsForProfile(profileId) } val pagingItems = auditEventPagingFlow.collectAsLazyPagingItems() val listState = rememberLazyListState() diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt index 5155c9ea..df480305 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt @@ -59,12 +59,11 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium @Composable fun DeviceSecuritySettingsScreen( - settingsController: SettingsController, onBack: () -> Unit, onClickProtectionMode: (SettingsData.AuthenticationMode) -> Unit ) { + val settingsController = rememberSettingsController() val authenticationModeState by settingsController.authenticationModeState - val listState = rememberLazyListState() var showBiometricPrompt by rememberSaveable { mutableStateOf(false) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsController.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsController.kt index fbba7119..669c5064 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsController.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsController.kt @@ -26,15 +26,9 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.core.content.edit -import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier -import androidx.paging.PagingData import de.gematik.ti.erp.app.ScreenshotsAllowed import de.gematik.ti.erp.app.analytics.Analytics import de.gematik.ti.erp.app.di.ApplicationPreferencesTag -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesWithPairedDevicesUseCase -import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.protocol.model.AuditEventData import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase import kotlinx.coroutines.flow.Flow @@ -47,8 +41,6 @@ import org.kodein.di.compose.rememberInstance @Suppress("TooManyFunctions") class SettingsController( private val settingsUseCase: SettingsUseCase, - private val profilesUseCase: ProfilesUseCase, - private val profilesWithPairedDevicesUseCase: ProfilesWithPairedDevicesUseCase, private val analytics: Analytics, private val appPrefs: SharedPreferences ) { @@ -84,23 +76,6 @@ class SettingsController( @Composable get() = zoomFlow.collectAsState(SettingStatesData.defaultZoomState) - private val profilesFlow = profilesUseCase.profiles.map { SettingStatesData.ProfilesState(it) } - - val profilesState - @Composable - get() = profilesFlow.collectAsState(SettingStatesData.defaultProfilesState) - - fun pairedDevices(profileId: ProfileIdentifier) = - profilesWithPairedDevicesUseCase.pairedDevices(profileId) - - // tag::DeletePairedDevicesViewModel[] - suspend fun deletePairedDevice(profileId: ProfileIdentifier, device: ProfilesUseCaseData.PairedDevice) = - profilesWithPairedDevicesUseCase.deletePairedDevices(profileId, device) - - // end::DeletePairedDevicesViewModel[] - fun decryptedAccessToken(profile: ProfilesUseCaseData.Profile) = - profilesUseCase.decryptedAccessToken(profile.id) - suspend fun onSelectDeviceSecurityAuthenticationMode() { settingsUseCase.saveAuthenticationMode( SettingsData.AuthenticationMode.DeviceSecurity @@ -134,29 +109,6 @@ class SettingsController( analytics.disallowTracking() } - suspend fun logout(profile: ProfilesUseCaseData.Profile) { - profilesUseCase.logout(profile) - } - - suspend fun addProfile(profileName: String) { - profilesUseCase.addProfile(profileName, activate = true) - } - - suspend fun removeProfile(profile: ProfilesUseCaseData.Profile, newProfileName: String?) { - if (newProfileName != null) { - profilesUseCase.removeAndSaveProfile(profile, newProfileName) - } else { - profilesUseCase.removeProfile(profile) - } - } - - suspend fun switchProfile(profile: ProfilesUseCaseData.Profile) { - profilesUseCase.switchActiveProfile(profile) - } - - fun loadAuditEventsForProfile(profileId: ProfileIdentifier): Flow> = - profilesUseCase.auditEvents(profileId) - suspend fun onboardingSucceeded( authenticationMode: SettingsData.AuthenticationMode, defaultProfileName: String, @@ -173,7 +125,6 @@ class SettingsController( } } - val authenticationMethod = settingsUseCase.authenticationMode var showOnboarding = runBlocking { settingsUseCase.showOnboarding.first() } var showWelcomeDrawer = runBlocking { settingsUseCase.showWelcomeDrawer } @@ -199,10 +150,6 @@ class SettingsController( settingsUseCase.acceptMlKit() } - suspend fun acceptUpdatedDataTerms() { - settingsUseCase.acceptUpdatedDataTerms() - } - suspend fun welcomeDrawerShown() { settingsUseCase.welcomeDrawerShown() } @@ -229,16 +176,12 @@ class SettingsController( @Composable fun rememberSettingsController(): SettingsController { val settingsUseCase by rememberInstance() - val profilesUseCase by rememberInstance() - val profilesWithPairedDevicesUseCase by rememberInstance() val analytics by rememberInstance() val appPrefs by rememberInstance(ApplicationPreferencesTag) return remember { SettingsController( settingsUseCase, - profilesUseCase, - profilesWithPairedDevicesUseCase, analytics, appPrefs ) @@ -275,19 +218,4 @@ object SettingStatesData { // `gemSpec_eRp_FdV A_20203` default settings does not allow screenshots val defaultScreenshotState = ScreenshotState(screenshotsAllowed = false) - - @Immutable - data class ProfilesState( - val profiles: List - ) { - fun activeProfile() = profiles.find { it.active }!! - fun profileById(profileId: String) = profiles.find { it.id == profileId } - fun containsProfileWithName(name: String) = profiles.any { - it.name.equals(name.trim(), true) - } - } - - val defaultProfilesState = ProfilesState( - profiles = listOf() - ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt index 854d0e6c..4f28835a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt @@ -20,7 +20,6 @@ package de.gematik.ti.erp.app.settings.ui import AccessibilitySettingsScreen import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -62,14 +61,12 @@ fun SettingsNavGraph( NavigationAnimation(mode = navigationMode) { SettingsScreenWithScaffold( mainNavController = mainNavController, - navController = settingsNavController, - settingsController = settingsController + navController = settingsNavController ) } } composable(SettingsNavigationScreens.AccessibilitySettings.route) { AccessibilitySettingsScreen( - settingsController = settingsController, onBack = { settingsNavController.popBackStack() } ) } @@ -93,7 +90,6 @@ fun SettingsNavGraph( composable(SettingsNavigationScreens.DeviceSecuritySettings.route) { DeviceSecuritySettingsScreen( - settingsController = settingsController, onBack = { settingsNavController.popBackStack() } ) { when (it) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt index 92d4ee3d..15d46b95 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt @@ -92,6 +92,9 @@ import de.gematik.ti.erp.app.card.model.command.UnlockMethod import de.gematik.ti.erp.app.cardwall.usecase.deviceHasNFC import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens import de.gematik.ti.erp.app.profiles.ui.Avatar +import de.gematik.ti.erp.app.profiles.ui.ProfilesController +import de.gematik.ti.erp.app.profiles.ui.ProfilesStateData +import de.gematik.ti.erp.app.profiles.ui.rememberProfilesController import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults @@ -128,10 +131,10 @@ fun SettingsScreen( @Composable fun SettingsScreenWithScaffold( mainNavController: NavController, - navController: NavController, - settingsController: SettingsController + navController: NavController ) { - val profilesState by settingsController.profilesState + val profilesController = rememberProfilesController() + val profilesState by profilesController.profilesState val listState = rememberLazyListState() @@ -247,7 +250,7 @@ private fun SettingsDivider() = @Composable private fun ProfileSection( - profilesState: SettingStatesData.ProfilesState, + profilesState: ProfilesStateData.ProfilesState, navController: NavController ) { val profiles = profilesState.profiles @@ -313,13 +316,13 @@ private fun ProfileCard( @Composable fun ProfileNameDialog( initialProfileName: String = "", - settingsController: SettingsController, + profilesController: ProfilesController, wantRemoveLastProfile: Boolean = false, onEdit: (text: String) -> Unit, onDismissRequest: () -> Unit ) { - val profilesState by settingsController.profilesState - var textValue by remember { mutableStateOf(initialProfileName ?: "") } + val profilesState by profilesController.profilesState + var textValue by remember { mutableStateOf(initialProfileName) } var duplicated by remember { mutableStateOf(false) } val title = if (wantRemoveLastProfile) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationController.kt similarity index 55% rename from android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationController.kt index d7fdd8f9..a8c824b6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationController.kt @@ -18,52 +18,62 @@ package de.gematik.ti.erp.app.userauthentication.ui -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.ViewModel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import de.gematik.ti.erp.app.settings.model.SettingsData import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberInstance -data class UserAuthenticationScreenState( - val authenticationMethod: SettingsData.AuthenticationMode, - val nrOfAuthFailures: Int -) - -class UserAuthenticationViewModel( +class AuthenticationController( private val authUseCase: AuthenticationUseCase -) : ViewModel() { - var defaultState = UserAuthenticationScreenState( - authenticationMethod = SettingsData.AuthenticationMode.Unspecified, - nrOfAuthFailures = 0 - ) - - fun screenState() = +) { + private val authenticationFlow = authUseCase.authenticationModeAndMethod.map { when (it) { AuthenticationModeAndMethod.None, - AuthenticationModeAndMethod.Authenticated -> UserAuthenticationScreenState( + AuthenticationModeAndMethod.Authenticated -> AuthenticationStateData.AuthenticationState( SettingsData.AuthenticationMode.Unspecified, 0 ) - is AuthenticationModeAndMethod.AuthenticationRequired -> UserAuthenticationScreenState( + is AuthenticationModeAndMethod.AuthenticationRequired -> AuthenticationStateData.AuthenticationState( it.method, it.nrOfFailedAuthentications ) } } + val authenticationState + @Composable + get() = authenticationFlow.collectAsState(AuthenticationStateData.defaultAuthenticationState) + suspend fun isPasswordValid(password: String): Boolean = authUseCase.isPasswordValid(password) - fun onAuthenticated() { - viewModelScope.launch { - authUseCase.resetNumberOfAuthenticationFailures() - } + suspend fun onAuthenticated() { + authUseCase.resetNumberOfAuthenticationFailures() authUseCase.authenticated() } - fun onFailedAuthentication() = - viewModelScope.launch { - authUseCase.incrementNumberOfAuthenticationFailures() - } + suspend fun onFailedAuthentication() = authUseCase.incrementNumberOfAuthenticationFailures() +} + +@Composable +fun rememberAuthenticationController(): AuthenticationController { + val authenticationUseCase by rememberInstance() + return remember { AuthenticationController(authenticationUseCase) } +} + +object AuthenticationStateData { + @Immutable + data class AuthenticationState( + val authenticationMethod: SettingsData.AuthenticationMode, + val nrOfAuthFailures: Int + ) + + val defaultAuthenticationState = AuthenticationState( + authenticationMethod = SettingsData.AuthenticationMode.Unspecified, + nrOfAuthFailures = 0 + ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt index 9dc170ac..b2b8047d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt @@ -36,16 +36,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -65,6 +62,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.Scaffold +import androidx.compose.runtime.LaunchedEffect import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.settings.model.SettingsData @@ -86,24 +85,16 @@ import de.gematik.ti.erp.app.utils.compose.annotatedLinkString import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource import de.gematik.ti.erp.app.utils.compose.annotatedStringResource import kotlinx.coroutines.launch -import org.kodein.di.compose.rememberViewModel import java.util.Locale @Suppress("LongMethod") @Composable fun UserAuthenticationScreen() { - val userAuthViewModel: UserAuthenticationViewModel by rememberViewModel() + val authentication = rememberAuthenticationController() + val scope = rememberCoroutineScope() var showAuthPrompt by remember { mutableStateOf(false) } var showError by remember { mutableStateOf(false) } var initiallyHandledAuthPrompt by rememberSaveable { mutableStateOf(false) } - val state by produceState(userAuthViewModel.defaultState) { - userAuthViewModel.screenState().collect { - value = it - if (!initiallyHandledAuthPrompt && it.nrOfAuthFailures == 0) { - showAuthPrompt = true - } - initiallyHandledAuthPrompt = true - } - } + val authenticationState by authentication.authenticationState val navBarInsetsPadding = WindowInsets.systemBars.asPaddingValues() val paddingModifier = if (navBarInsetsPadding.calculateBottomPadding() <= PaddingDefaults.Medium) { Modifier.statusBarsPadding() @@ -114,6 +105,10 @@ fun UserAuthenticationScreen() { val focusManager = LocalFocusManager.current LaunchedEffect(Unit) { focusManager.clearFocus(true) + if (!initiallyHandledAuthPrompt && authenticationState.nrOfAuthFailures == 0) { + showAuthPrompt = true + } + initiallyHandledAuthPrompt = true } Scaffold { @@ -150,13 +145,13 @@ fun UserAuthenticationScreen() { } else { AuthenticationScreenContent( showAuthPromptOnClick = { showAuthPrompt = true }, - state = state + state = authenticationState ) } Spacer(modifier = Modifier.weight(1f)) if (showError) { AuthenticationScreenErrorBottomContent( - state = state + state = authenticationState ) } else { Image( @@ -172,19 +167,19 @@ fun UserAuthenticationScreen() { } if (showAuthPrompt) { - when (state.authenticationMethod) { + when (authenticationState.authenticationMethod) { is SettingsData.AuthenticationMode.Password -> PasswordPrompt( - userAuthViewModel, + authentication, onAuthenticated = { showAuthPrompt = false - userAuthViewModel.onAuthenticated() + scope.launch { authentication.onAuthenticated() } }, onCancel = { showAuthPrompt = false }, onAuthenticationError = { - userAuthViewModel.onFailedAuthentication() + scope.launch { authentication.onFailedAuthentication() } showAuthPrompt = false showError = true } @@ -196,18 +191,18 @@ fun UserAuthenticationScreen() { negativeButton = stringResource(R.string.auth_prompt_cancel), onAuthenticated = { showAuthPrompt = false - userAuthViewModel.onAuthenticated() + scope.launch { authentication.onAuthenticated() } }, onCancel = { showAuthPrompt = false }, onAuthenticationError = { - userAuthViewModel.onFailedAuthentication() + scope.launch { authentication.onFailedAuthentication() } showAuthPrompt = false showError = true }, onAuthenticationSoftError = { - userAuthViewModel.onFailedAuthentication() + scope.launch { authentication.onFailedAuthentication() } } ) } @@ -261,7 +256,7 @@ private fun AuthenticationScreenErrorContent( } @Composable -private fun AuthenticationScreenErrorBottomContent(state: UserAuthenticationScreenState) { +private fun AuthenticationScreenErrorBottomContent(state: AuthenticationStateData.AuthenticationState) { Column( modifier = Modifier .background(color = AppTheme.colors.neutral100) @@ -300,7 +295,7 @@ private fun AuthenticationScreenErrorBottomContent(state: UserAuthenticationScre @Composable private fun AuthenticationScreenContent( showAuthPromptOnClick: () -> Unit, - state: UserAuthenticationScreenState + state: AuthenticationStateData.AuthenticationState ) { Column( modifier = Modifier @@ -373,7 +368,7 @@ private fun AuthenticationScreenContent( @Composable private fun PasswordPrompt( - viewModel: UserAuthenticationViewModel, + authenticationState: AuthenticationController, onAuthenticated: () -> Unit, onCancel: () -> Unit, onAuthenticationError: () -> Unit @@ -396,7 +391,7 @@ private fun PasswordPrompt( enabled = password.isNotEmpty(), onClick = { coroutineScope.launch { - if (viewModel.isPasswordValid(password)) { + if (authenticationState.isPasswordValid(password)) { onAuthenticated() } else { onAuthenticationError() diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt index 9cc500a7..bdcfd9a5 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt @@ -473,7 +473,9 @@ fun CommonAlertDialog( header: String?, info: String, cancelText: String, + cancelTextColor: Color? = null, actionText: String, + actionTextColor: Color? = null, enabled: Boolean = true, onCancel: () -> Unit, onClickAction: () -> Unit @@ -490,14 +492,18 @@ fun CommonAlertDialog( onClick = onCancel, enabled = enabled ) { - Text(cancelText) + cancelTextColor?.let { + Text(cancelText, color = it) + } ?: Text(cancelText) } TextButton( modifier = Modifier.testTag(TestTag.AlertDialog.ConfirmButton), onClick = onClickAction, enabled = enabled ) { - Text(actionText) + actionTextColor?.let { + Text(actionText, color = it) + } ?: Text(actionText) } } ) diff --git a/android/src/main/res/drawable-xhdpi/share_sheet.webp b/android/src/main/res/drawable-xhdpi/share_sheet.webp new file mode 100644 index 00000000..88773f3c Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/share_sheet.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/share_sheet.webp b/android/src/main/res/drawable-xxhdpi/share_sheet.webp new file mode 100644 index 00000000..cf3cdd70 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/share_sheet.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/share_sheet.webp b/android/src/main/res/drawable-xxxhdpi/share_sheet.webp new file mode 100644 index 00000000..d2c2d1ac Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/share_sheet.webp differ diff --git a/android/src/main/res/raw/health_insurance_contacts.json b/android/src/main/res/raw/health_insurance_contacts.json index 2fb1484d..1ef7842a 100644 --- a/android/src/main/res/raw/health_insurance_contacts.json +++ b/android/src/main/res/raw/health_insurance_contacts.json @@ -2,13 +2,13 @@ { "name": "AOK Baden-Württemberg", "healthCardAndPinPhone": null, - "healthCardAndPinMail": "a99_egk@bw.aok.de", - "healthCardAndPinUrl": null, - "pinUrl": null, - "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", - "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", - "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", - "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.aok.de/pk/versichertenservice/elektronische-gesundheitskarte-egk/", + "pinUrl": "https://www.aok.de/pk/versichertenservice/pin-zur-elektronischen-gesundheitskarte/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Bayern", @@ -143,7 +143,7 @@ "bodyPinMail": null }, { - "name": "BKK Deutsche Bahn AG", + "name": "BAHN-BKK", "healthCardAndPinPhone": null, "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bahn-bkk.de/egk-erezept", diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 0c54f944..8c4274f9 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -544,7 +544,7 @@ Keine Tokens Sie erhalten einen Token, wenn Sie am Rezeptdienst angemeldet sind.\n Bestellungen - https://t.maze.co/90489290 + https://gematik.shortcm.li/E-Rezept-App_Feedback Wunsch-PIN wählen Karte entsperren PIN wählen @@ -825,17 +825,62 @@ Ihre Versicherung bietet folgende Kontaktmöglichkeiten Ihre Versicherung bietet folgende Kontaktmöglichkeit Schließen - Abrechnungen - Abrechnungen anzeigen - Abrechnungen - Um Abrechnungen zu empfangen, müssen Sie mit dem Server verbunden sein. + Kostenbelege + Kostenbelege anzeigen + Kostenbelege + Um Kostenbelege zu empfangen, müssen Sie mit dem Server verbunden sein. Verbinden - Keine Abrechnungen + Keine Kostenbelege Deaktivieren Abbrechen Funktion deaktivieren - Hierdurch werden alle Abrechnungen von diesem Gerät und vom Server gelöscht. - Abrechnungen empfangen - Ihre Abrechnungen werden zusätzlich auf dem Rezeptserver gespeichert. + Hierdurch werden alle Kostenbelege von diesem Gerät und vom Server gelöscht. + Kostenbelege empfangen + Ihre Kostenbelege werden zusätzlich auf dem Rezeptserver gespeichert. Empfangen + Gesamt: %s %s + Auswählen + Teilen + Löschen + Löschen + Einreichen + %s € + Gesamtpreis + Tipp: Kostenbelege über Versicherungs-App einreichen + Reichen Sie Kostenbelege unkompliziert über die App Ihrer Versicherung ein. Wählen Sie dafür im nächsten Schritt diese App aus und drücken Sie auf Teilen. + Praxis + Apotheke + Datum + Mehr anzeigen + Arzneimittel-ID + Augestellt für + KVNR: %s + Geboren am: %s + LANR: %s + IKNR: %s + Okay + Wie reichen Sie Belege ein? + Direkt in die App Ihrer Versicherung/Beihilfestelle übertragen. Wählen Sie hierfür die App auf der nächsten Seite aus. + oder + Datei speichern und später in das Portal der Versicherung/Beihilfe importieren. + HMKNR %s + PZN %s + TA1 %s + Artikel: %s + Anzahl: %s + MwSt: %s %% + Bruttopreis in EUR: %s + Zusätzliche Gebühren + Notdienstgebühr + BTM-Gebühr + T-Rezept Gebühr + Beschaffungskosten + Botendienst + Gesamtsumme in EUR: %s + Abgabe + Wirklich löschen? + Die Datei wird von Ihrem Gerät und vom Server gelöscht. + Löschen + + diff --git a/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCaseTest.kt new file mode 100644 index 00000000..04f65d5a --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCaseTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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. + * + */ + +@file:Suppress("ktlint:max-line-length", "ktlint:argument-list-wrapping") + +package de.gematik.ti.erp.app.invoice.usecase + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.invoice.repository.InvoiceRepository +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.BeforeTest + +import kotlin.test.assertEquals + +@ExperimentalCoroutinesApi +class InvoiceUseCaseTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + @MockK + lateinit var useCase: InvoiceUseCase + + @MockK + lateinit var repositpry: InvoiceRepository + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + useCase = spyk( + InvoiceUseCase(repositpry, coroutineRule.dispatchers) + ) + } + + @Test + fun `invoices - should return invoices sorted by timestamp and grouped by year`() = + + runTest { + every { useCase.invoicesFlow("1234") } returns flowOf(listOf(pkvInvoice, pkvInvoice2)) + + val invoices = useCase.invoices("1234").first() + + println("size : " + invoices.size) + assertEquals( + mapOf( + Pair( + first = later.toLocalDateTime(TimeZone.currentSystemDefault()).year, + second = listOf(pkvInvoice2) + ), + Pair( + first = now.toLocalDateTime(TimeZone.currentSystemDefault()).year, + second = listOf(pkvInvoice) + ) + ), + invoices + ) + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/TestInvoices.kt b/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/TestInvoices.kt new file mode 100644 index 00000000..0ce31a9d --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/invoice/usecase/TestInvoices.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.usecase + +import de.gematik.ti.erp.app.fhir.parser.asFhirTemporal +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.plus + +val now = Clock.System.now() +val later = now.plus(8760, DateTimeUnit.HOUR) + +val pkvInvoice = InvoiceData.PKVInvoice( + profileId = "1234", + taskId = "01234", + timestamp = now, + invoice = InvoiceData.Invoice( + 2.30, + 6.80, + "EUR", + listOf() + ), + pharmacyOrganization = SyncedTaskData.Organization( + "Pharmacy", + SyncedTaskData.Address("", "", ""), + null, + null, + null + ), + practitionerOrganization = SyncedTaskData.Organization( + "Practitioner", + SyncedTaskData.Address("", "", ""), + null, + null, + null + ), + practitioner = SyncedTaskData.Practitioner("Practitioner", "", ""), + patient = SyncedTaskData.Patient( + "Patient", + SyncedTaskData.Address("", "", ""), + null, + null + ), + medicationRequest = SyncedTaskData.MedicationRequest( + null, null, null, SyncedTaskData.AccidentType.None, + null, null, false, null, + SyncedTaskData.MultiplePrescriptionInfo(false), 1, null, null, SyncedTaskData.AdditionalFee.None + ), + whenHandedOver = now.asFhirTemporal() + +) + +val pkvInvoice2 = InvoiceData.PKVInvoice( + profileId = "23456", + taskId = "65432", + timestamp = later, + invoice = InvoiceData.Invoice( + 2.30, + 6.80, + "EUR", + listOf() + ), + pharmacyOrganization = SyncedTaskData.Organization( + "Pharmacy", + SyncedTaskData.Address("", "", ""), + null, + null, + null + ), + practitionerOrganization = SyncedTaskData.Organization( + "Practitioner", + SyncedTaskData.Address("", "", ""), + null, + null, + null + ), + practitioner = SyncedTaskData.Practitioner("Practitioner", "", ""), + patient = SyncedTaskData.Patient( + "Patient", + SyncedTaskData.Address("", "", ""), + null, + null + ), + medicationRequest = SyncedTaskData.MedicationRequest( + null, null, null, SyncedTaskData.AccidentType.None, + null, null, false, null, + SyncedTaskData.MultiplePrescriptionInfo(false), 1, null, null, SyncedTaskData.AdditionalFee.None + ), + whenHandedOver = later.asFhirTemporal() + +) diff --git a/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt index 5395d628..41395188 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt @@ -18,6 +18,7 @@ package de.gematik.ti.erp.app.utils +import de.gematik.ti.erp.app.fhir.parser.FhirTemporal import de.gematik.ti.erp.app.prescription.model.ScannedTaskData import de.gematik.ti.erp.app.prescription.model.SyncedTaskData import kotlinx.datetime.Instant @@ -35,7 +36,7 @@ fun syncedTask( authoredOn: Instant, status: SyncedTaskData.TaskStatus, medicationName: String, - medicationDispenseWhenHandedOver: Instant? = null + medicationDispenseWhenHandedOver: FhirTemporal? = null ) = SyncedTaskData.SyncedTask( profileId = "", diff --git a/build.gradle.kts b/build.gradle.kts index 25dcf032..58f9f0c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,8 +18,8 @@ plugins { id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false id("io.realm.kotlin") version "1.6.1" apply false id("org.jetbrains.kotlin.android") version "1.7.20" apply false - id("com.android.application") version "7.3.1" apply false - id("com.android.library") version "7.3.1" apply false + id("com.android.application") version "7.4.1" apply false + id("com.android.library") version "7.4.1" apply false id("org.jetbrains.compose") version "1.3.0" apply false id("com.codingfeline.buildkonfig") version "0.13.3" apply false id("io.gitlab.arturbosch.detekt") version "1.22.0" diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt index 2c925a6b..2f4dc83e 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt @@ -60,6 +60,7 @@ interface ErpService { @GET("AuditEvent") suspend fun getAuditEvents( @Tag profileId: ProfileIdentifier, + @Header("Accept-Language") language: String, @Query("date") lastKnownDate: String?, @Query("_sort") sort: String = "+date", @Query("_count") count: Int? = null, @@ -88,6 +89,7 @@ interface ErpService { @Query("identifier") id: String ): Response + // PKV consent @GET("Consent") suspend fun getConsent( @Tag profileId: ProfileIdentifier @@ -104,4 +106,26 @@ interface ErpService { @Tag profileId: ProfileIdentifier, @Query("category") category: String ): Response + + // PKV Invoices + @GET("ChargeItem") + suspend fun getChargeItems( + @Tag profileId: ProfileIdentifier, + @Query("modified") lastUpdated: String?, + @Query("_sort") sort: String = "modified", + @Query("_count") count: Int? = null, + @Query("__offset") offset: Int? = null + ): Response + + @GET("ChargeItem/{id}") + suspend fun getChargeItemBundleById( + @Tag profileId: ProfileIdentifier, + @Path("id") id: String + ): Response + + @DELETE("ChargeItem/{id}") + suspend fun deleteChargeItemById( + @Tag profileId: ProfileIdentifier, + @Path("id") id: String + ): Response } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt index ff18ec63..923493f9 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt @@ -44,4 +44,9 @@ interface PharmacySearchService { suspend fun searchByTelematikId( @Query("identifier") telematikId: String ): Response + + @GET("api/Binary") + suspend fun searchBinary( + @Query("_securityContext") locationId: String + ): Response } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt index c154a362..c423c360 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt @@ -24,6 +24,7 @@ import de.gematik.ti.erp.app.db.entities.v1.AvatarFigureV1 import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 import de.gematik.ti.erp.app.db.entities.v1.IdpConfigurationEntityV1 import de.gematik.ti.erp.app.db.entities.v1.InsuranceTypeV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 import de.gematik.ti.erp.app.db.entities.v1.pharmacy.PharmacyCacheEntityV1 import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 @@ -31,6 +32,9 @@ import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 import de.gematik.ti.erp.app.db.entities.v1.TruststoreEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 import de.gematik.ti.erp.app.db.entities.v1.pharmacy.FavoritePharmacyEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 @@ -49,8 +53,9 @@ import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 import io.realm.kotlin.ext.query +import io.realm.kotlin.ext.realmListOf -const val ACTUAL_SCHEMA_VERSION = 17L +const val ACTUAL_SCHEMA_VERSION = 19L val appSchemas = setOf( AppRealmSchema( @@ -82,7 +87,12 @@ val appSchemas = setOf( PharmacyCacheEntityV1::class, OftenUsedPharmacyEntityV1::class, MultiplePrescriptionInfoEntityV1::class, - FavoritePharmacyEntityV1::class + FavoritePharmacyEntityV1::class, + IngredientEntityV1::class, + PKVInvoiceEntityV1::class, + InvoiceEntityV1::class, + ChargeableItemV1::class, + PriceComponentV1::class ), migrateOrInitialize = { migrationStartedFrom -> queryFirst() ?: run { @@ -131,6 +141,24 @@ val appSchemas = setOf( } } } + if (migrationStartedFrom < 18L) { + query().find().forEach { + it.invoices = realmListOf() + } + query().find().forEach { + if (it._authoredOn?.isEmpty() == true) { + it._authoredOn = null + } + } + } + + if (migrationStartedFrom < 19L) { + query().find().forEach { + if (it._handedOverOn?.isEmpty() == true) { + it._handedOverOn = null + } + } + } } ) ) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt index 703f4575..fd8b3b4b 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt @@ -21,6 +21,7 @@ package de.gematik.ti.erp.app.db.entities.v1 import de.gematik.ti.erp.app.db.entities.Cascading import de.gematik.ti.erp.app.db.entities.byteArrayBase64Nullable import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 import io.realm.kotlin.Deleteable @@ -105,6 +106,7 @@ class ProfileEntityV1 : RealmObject, Cascading { var syncedTasks: RealmList = realmListOf() var scannedTasks: RealmList = realmListOf() + var invoices: RealmList = realmListOf() var idpAuthenticationData: IdpAuthenticationDataEntityV1? = null var auditEvents: RealmList = realmListOf() @@ -114,6 +116,7 @@ class ProfileEntityV1 : RealmObject, Cascading { yield(syncedTasks) yield(scannedTasks) yield(auditEvents) + yield(invoices) idpAuthenticationData?.let { yield(it) } } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/ChargeableItem.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/ChargeableItem.kt new file mode 100644 index 00000000..09d7ec29 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/ChargeableItem.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.db.entities.v1.invoice + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.enumName +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +enum class DescriptionTypeV1 { + PZN, + TA1, + HMNR, ; +} + +class ChargeableItemV1 : RealmObject, Cascading { + + var _description: String = DescriptionTypeV1.PZN.toString() + + @delegate:Ignore + var descriptionTypeV1: DescriptionTypeV1 by enumName(::_description) + var description: String = "" + + var factor: Double = 0.0 + + var price: PriceComponentV1? = null + + override fun objectsToFollow(): Iterator = + iterator { + price?.let { yield(it) } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/Invoice.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/Invoice.kt new file mode 100644 index 00000000..dc37a846 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/Invoice.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.db.entities.v1.invoice + +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.ext.realmListOf + +class InvoiceEntityV1 : RealmObject, Cascading { + + var totalAdditionalFee: Double = 0.0 + var totalBruttoAmount: Double = 0.0 + var currency: String = "" + var chargeableItems: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + yield(chargeableItems) + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PKVInvoice.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PKVInvoice.kt new file mode 100644 index 00000000..67848d8a --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PKVInvoice.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.db.entities.v1.invoice + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.byteArrayBase64 +import de.gematik.ti.erp.app.db.entities.temporalAccessorNullable +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.fhir.parser.FhirTemporal +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +class PKVInvoiceEntityV1 : RealmObject, Cascading { + + var taskId: String = "" + + var timestamp: RealmInstant = RealmInstant.MIN + + var pharmacyOrganization: OrganizationEntityV1? = null + var practitionerOrganization: OrganizationEntityV1? = null + var patient: PatientEntityV1? = null + var practitioner: PractitionerEntityV1? = null + var medicationRequest: MedicationRequestEntityV1? = null + + var _whenHandedOver: String? = null + + @delegate:Ignore + var whenHandedOver: FhirTemporal? by temporalAccessorNullable(::_whenHandedOver) + + var invoice: InvoiceEntityV1? = null + + var _invoiceBinary: String = "" + + @delegate:Ignore + var invoiceBinary: ByteArray by byteArrayBase64(::_invoiceBinary) + + var _kbvBinary: String = "" + + @delegate:Ignore + var kbvBinary: ByteArray by byteArrayBase64(::_kbvBinary) + + var _erpPrBinary: String = "" + + @delegate:Ignore + var erpPrBinary: ByteArray by byteArrayBase64(::_erpPrBinary) + + // back reference + var parent: ProfileEntityV1? = null + + override fun objectsToFollow(): Iterator = + iterator { + pharmacyOrganization?.let { yield(it) } + invoice?.let { yield(it) } + pharmacyOrganization?.let { yield(it) } + practitionerOrganization?.let { yield(it) } + practitioner?.let { yield(it) } + patient?.let { yield(it) } + medicationRequest?.let { yield(it) } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PriceComponent.kt similarity index 55% rename from android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PriceComponent.kt index 10d1cf84..26a9441c 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/invoice/PriceComponent.kt @@ -16,20 +16,12 @@ * */ -package de.gematik.ti.erp.app.orderhealthcard.ui.model +package de.gematik.ti.erp.app.db.entities.v1.invoice -import androidx.compose.runtime.Immutable -import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData +import io.realm.kotlin.types.RealmObject -object HealthCardOrderViewModelData { - @Immutable - data class State( - val companies: List, - val selectedCompany: HealthCardOrderUseCaseData.HealthInsuranceCompany?, - val selectedOption: ContactInsuranceOption - ) +class PriceComponentV1 : RealmObject { - enum class ContactInsuranceOption { - WithHealthCardAndPin, PinOnly - } + var value: Double = 0.0 + var tax: Double = 0.0 } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt index 3d3a9190..3ceb61b0 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt @@ -19,9 +19,11 @@ package de.gematik.ti.erp.app.db.entities.v1.task import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.temporalAccessorNullable +import de.gematik.ti.erp.app.fhir.parser.FhirTemporal import io.realm.kotlin.Deleteable -import io.realm.kotlin.types.RealmInstant import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore class MedicationDispenseEntityV1 : RealmObject, Cascading { var dispenseId: String = "" @@ -30,7 +32,11 @@ class MedicationDispenseEntityV1 : RealmObject, Cascading { var wasSubstituted: Boolean = false var dosageInstruction: String? = null var performer: String = "" // Telematik-ID - var whenHandedOver: RealmInstant = RealmInstant.MIN + + var _handedOverOn: String? = null + + @delegate:Ignore + var handedOverOn: FhirTemporal? by temporalAccessorNullable(::_handedOverOn) override fun objectsToFollow(): Iterator = iterator { medication?.let { yield(it) } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt index ce208c54..3896e7ef 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt @@ -20,6 +20,8 @@ package de.gematik.ti.erp.app.db.entities.v1.task import de.gematik.ti.erp.app.db.entities.Cascading import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.temporalAccessorNullable +import de.gematik.ti.erp.app.fhir.parser.FhirTemporal import io.realm.kotlin.Deleteable import io.realm.kotlin.types.RealmInstant import io.realm.kotlin.types.RealmObject @@ -35,6 +37,11 @@ enum class AccidentTypeV1 { @Suppress("LongParameterList") class MedicationRequestEntityV1 : RealmObject, Cascading { var medication: MedicationEntityV1? = null + var _authoredOn: String? = null + + @delegate:Ignore + var authoredOn: FhirTemporal? by temporalAccessorNullable(::_authoredOn) + var dateOfAccident: RealmInstant? = null // unfalltag var location: String? = null // unfallbetrieb @delegate:Ignore diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PKVInvoiceMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapper.kt similarity index 61% rename from common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PKVInvoiceMapper.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapper.kt index a3519198..2a7aed61 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PKVInvoiceMapper.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapper.kt @@ -20,6 +20,7 @@ package de.gematik.ti.erp.app.fhir.model import de.gematik.ti.erp.app.fhir.parser.FhirTemporal import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull import de.gematik.ti.erp.app.fhir.parser.containedDouble import de.gematik.ti.erp.app.fhir.parser.containedString import de.gematik.ti.erp.app.fhir.parser.filterWith @@ -28,36 +29,11 @@ import de.gematik.ti.erp.app.fhir.parser.isProfileValue import de.gematik.ti.erp.app.fhir.parser.or import de.gematik.ti.erp.app.fhir.parser.stringValue import de.gematik.ti.erp.app.fhir.parser.toFhirTemporal +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import kotlinx.datetime.Instant +import kotlinx.datetime.toInstant import kotlinx.serialization.json.JsonElement -enum class SpecialPZN(val pzn: String) { - EmergencyServiceFee("02567018"), - BTMFee("02567001"), - TPrescriptionFee("06460688"), - ProvisioningCosts("09999637"), - DeliveryServiceCosts("06461110"); - - companion object { - fun isAnyOf(pzn: String): Boolean = values().any { it.pzn == pzn } - - fun valueOfPZN(pzn: String) = SpecialPZN.values().find { it.pzn == pzn } - } -} - -data class ChargeableItem(val description: Description, val factor: Double, val price: PriceComponent) { - sealed interface Description { - data class PZN(val pzn: String) : Description { - fun isSpecialPZN() = SpecialPZN.isAnyOf(pzn) - } - - data class TA1(val ta1: String) : Description - - data class HMNR(val hmnr: String) : Description - } -} - -data class PriceComponent(val value: Double, val tax: Double) - typealias PkvDispenseFn = ( whenHandedOver: FhirTemporal ) -> R @@ -66,27 +42,91 @@ typealias InvoiceFn = ( totalAdditionalFee: Double, totalBruttoAmount: Double, currency: String, - items: List + items: List ) -> R -fun extractPKVInvoiceBundle( +fun extractInvoiceKBVAndErpPrBundle( + bundle: JsonElement, + process: ( + taskId: String, + invoiceBundle: JsonElement, + kbvBundle: JsonElement, + erpPrBundle: JsonElement + ) -> Unit +) { + val resources = bundle + .findAll("entry.resource") + + lateinit var invoiceBundle: JsonElement + lateinit var kbvBundle: JsonElement + lateinit var erpPrBundle: JsonElement + var taskId = "" + + resources.forEach { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + when { + profileString.isProfileValue( + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle", + "1.1" + ) -> { + taskId = resource + .findAll("identifier") + .filterWith( + "system", + stringValue("https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId") + ) + .firstOrNull() + ?.containedString("value") ?: "" + + invoiceBundle = resource + } + + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle", + "1.1.0" + ) -> { + kbvBundle = resource + } + + profileString.isProfileValue( + "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Bundle", + "1.2" + ) -> { + erpPrBundle = resource + } + } + } + process(taskId, invoiceBundle, kbvBundle, erpPrBundle) +} +fun extractBinary(erpPrBundle: JsonElement): ByteArray? { + return erpPrBundle.contained("signature").containedString("data").toByteArray() +} + +fun extractInvoiceBundle( bundle: JsonElement, processDispense: PkvDispenseFn, processPharmacyAddress: AddressFn, processPharmacy: OrganizationFn, processInvoice: InvoiceFn, save: ( + taskId: String, + timestamp: Instant, pharmacy: Pharmacy, invoice: Invoice, dispense: Dispense - ) -> R -): R? { + ) -> Unit +) { val profileString = bundle.contained("meta").contained("profile").contained() - return when { + + when { profileString.isProfileValue( "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle", "1.1" - ) -> extractPKVInvoiceBundleVersion11( + ) -> extractInvoiceBundleVersion11( bundle, processDispense, processPharmacyAddress, @@ -94,23 +134,32 @@ fun extractPKVInvoiceBundle( processInvoice, save ) - - else -> null } } -fun extractPKVInvoiceBundleVersion11( +fun extractInvoiceBundleVersion11( bundle: JsonElement, processDispense: PkvDispenseFn, processPharmacyAddress: AddressFn, processPharmacy: OrganizationFn, processInvoice: InvoiceFn, save: ( + taskId: String, + timestamp: Instant, pharmacy: Pharmacy, invoice: Invoice, dispense: Dispense ) -> R ): R { + val taskId = bundle.findAll("identifier").filterWith( + "system", + stringValue("https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId") + ) + .firstOrNull() + ?.containedString("value") + + val timestamp = bundle.containedString("timestamp").toInstant() + val resources = bundle .findAll("entry.resource") @@ -159,6 +208,8 @@ fun extractPKVInvoiceBundleVer } return save( + requireNotNull(taskId) { "TaskId missing" }, + timestamp, requireNotNull(pharmacy) { "Pharmacy missing" }, requireNotNull(invoice) { "Invoice missing" }, requireNotNull(dispense) { "Dispense missing" } @@ -226,16 +277,16 @@ fun extractInvoice( .firstOrNull() ?.let { val code = it.containedString("code") - val price = PriceComponent(value, tax) + val price = InvoiceData.PriceComponent(value, tax) when (it.containedString("system")) { "http://fhir.de/CodeSystem/ifa/pzn" -> - ChargeableItem(ChargeableItem.Description.PZN(code), factor, price) + InvoiceData.ChargeableItem(InvoiceData.ChargeableItem.Description.PZN(code), factor, price) "http://TA1.abda.de" -> - ChargeableItem(ChargeableItem.Description.TA1(code), factor, price) + InvoiceData.ChargeableItem(InvoiceData.ChargeableItem.Description.TA1(code), factor, price) "http://fhir.de/sid/gkv/hmnr" -> - ChargeableItem(ChargeableItem.Description.HMNR(code), factor, price) + InvoiceData.ChargeableItem(InvoiceData.ChargeableItem.Description.HMNR(code), factor, price) else -> null } @@ -251,3 +302,37 @@ fun extractInvoice( items ) } + +fun extractTaskIdsFromChargeItemBundle( + bundle: JsonElement +): Pair> { + val bundleTotal = bundle.containedArrayOrNull("entry")?.size ?: 0 + val resources = bundle + .findAll("entry.resource") + + val taskIds = resources.mapNotNull { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + when { + profileString.isProfileValue( + "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_PR_ChargeItem", + "1.0" + ) -> + resource + .findAll("identifier") + .filterWith( + "system", + stringValue("https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId") + ) + .first() + .containedString("value") + + else -> null + } + } + + return bundleTotal to taskIds.toList() +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt index 94217704..8d15e164 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt @@ -57,6 +57,7 @@ typealias InsuranceInformationFn = ( ) -> R typealias MedicationRequestFn = ( + authoredOn: FhirTemporal.LocalDate?, dateOfAccident: FhirTemporal.LocalDate?, location: String?, accidentType: AccidentType, diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt index d931d79e..ddc74b07 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt @@ -19,7 +19,6 @@ package de.gematik.ti.erp.app.fhir.model import de.gematik.ti.erp.app.fhir.parser.FhirTemporal -import de.gematik.ti.erp.app.fhir.parser.asFhirLocalDate import de.gematik.ti.erp.app.fhir.parser.contained import de.gematik.ti.erp.app.fhir.parser.containedArray import de.gematik.ti.erp.app.fhir.parser.containedBooleanOrNull @@ -27,9 +26,9 @@ import de.gematik.ti.erp.app.fhir.parser.containedOrNull import de.gematik.ti.erp.app.fhir.parser.containedString import de.gematik.ti.erp.app.fhir.parser.containedStringOrNull import de.gematik.ti.erp.app.fhir.parser.isProfileValue +import de.gematik.ti.erp.app.fhir.parser.toFhirTemporal import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonPrimitive -import kotlinx.datetime.LocalDate typealias MedicationDispenseFn = ( dispenseId: String, @@ -38,7 +37,7 @@ typealias MedicationDispenseFn = ( wasSubstituted: Boolean, dosageInstruction: String?, performer: String, // Telematik-ID - whenHandedOver: FhirTemporal.LocalDate + whenHandedOver: FhirTemporal ) -> R fun extractMedicationDispense( @@ -63,7 +62,7 @@ fun extractMedicat val dosageInstruction = resource.containedOrNull("dosageInstruction")?.containedStringOrNull("text") val performer = resource.containedArray("performer")[0] .contained("actor").contained("identifier").containedString("value") // Telematik-ID - val whenHandedOver = resource.contained("whenHandedOver").jsonPrimitive.asFhirLocalDate() + val whenHandedOver = resource.contained("whenHandedOver").jsonPrimitive.toFhirTemporal() ?: error("error on parsing date of delivery") return processMedicationDispense( diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt index 2c2ee92a..9c6163ca 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt @@ -161,6 +161,19 @@ fun extractPharmacyServices( ) } +/** + * Extract certificates from binary bundle. + */ +fun extractBinaryCertificateAsBase64( + bundle: JsonElement +): String { + val resource = bundle.findAll(listOf("entry", "resource")).first() + + require(resource.containedString("contentType") == "application/pkix-cert") + + return resource.containedString("data") +} + private fun Sequence.mapCatching( onError: (JsonElement, Exception) -> Unit, transform: (JsonElement) -> R? diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_0_2.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_0_2.kt index 280a158a..cd63a294 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_0_2.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_0_2.kt @@ -278,6 +278,7 @@ fun extractMedica ?.containedStringOrNull("code") return processMedicationRequest( + null, dateOfAccident, location, accidentType, diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_1_0.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_1_0.kt index 4e6b66d5..8ed1b480 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_1_0.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/ResourceMapperVersion_1_1_0.kt @@ -210,6 +210,7 @@ fun extractMedica ratioFn: RatioFn, quantityFn: QuantityFn ): MedicationRequest { + val authoredOn = resource.contained("authoredOn").contained().jsonPrimitive.asFhirLocalDate() val accidentInformation = resource .findAll("extension") .filterWith( @@ -279,6 +280,7 @@ fun extractMedica ?.containedStringOrNull("code") return processMedicationRequest( + authoredOn, dateOfAccident, location, accidentType, @@ -302,9 +304,25 @@ fun extractPatientVersion110( val birthDate = resource.containedOrNull("birthDate")?.jsonPrimitive?.toFhirTemporal() + val kvnrSystem = resource.containedOrNull("identifier")?.containedOrNull("type") + ?.findAll("coding") + ?.filterWith( + "system", + stringValue( + "http://fhir.de/CodeSystem/identifier-type-de-basis" + ) + ) + ?.firstOrNull() + ?.containedString("code") + + val system = when (kvnrSystem) { + "PKV" -> "http://fhir.de/sid/pkv/kvid-10" + else -> "http://fhir.de/sid/gkv/kvid-10" + } + val kvnr = resource .findAll("identifier") - .filterWith("system", stringValue("http://fhir.de/sid/gkv/kvid-10")) + .filterWith("system", stringValue(system)) .firstOrNull() ?.containedString("value") diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/TemporalConverter.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/TemporalConverter.kt index 076e0427..a6f71a74 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/TemporalConverter.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/TemporalConverter.kt @@ -24,6 +24,9 @@ import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant import kotlin.jvm.JvmInline /** @@ -72,6 +75,14 @@ sealed interface FhirTemporal { is Year -> this.value.toString() is YearMonth -> this.value.toString() } + + fun toInstant(timeZone: TimeZone = TimeZone.currentSystemDefault()): kotlinx.datetime.Instant = + when (this) { + is Instant -> this.value + is LocalDate -> this.value.atStartOfDayIn(timeZone) + is LocalDateTime -> this.value.toInstant(timeZone) + is LocalTime, is Year, is YearMonth -> error("invalid format") + } } fun Instant.asFhirTemporal() = FhirTemporal.Instant(this) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/HtmlTemplate.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/HtmlTemplate.kt similarity index 51% rename from common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/HtmlTemplate.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/HtmlTemplate.kt index a39cbd62..d50e0a44 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/HtmlTemplate.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/HtmlTemplate.kt @@ -16,31 +16,30 @@ * */ -package de.gematik.ti.erp.app.invoice.usecase +package de.gematik.ti.erp.app.invoice.model -import de.gematik.ti.erp.app.fhir.model.ChargeableItem -import de.gematik.ti.erp.app.fhir.model.SpecialPZN +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData object PkvHtmlTemplate { - fun createOrganization( + private fun createOrganization( organizationName: String, organizationAddress: String, organizationIKNR: String? ) = "$organizationName
$organizationAddress${organizationIKNR?.let { "
IKNR: $it" } ?: ""}" - fun createPrescriber( + private fun createPrescriber( prescriberName: String, prescriberAddress: String, prescriberLANR: String ) = "$prescriberName
$prescriberAddress
LANR: $prescriberLANR" - fun createPatient( + private fun createPatient( patientName: String, patientAddress: String, patientKVNR: String - ) = "$patientName
$patientAddress
KVNR: $patientKVNR" + ) = "$patientName
$patientAddress
KVNr: $patientKVNR" - fun createArticle( + private fun createArticle( article: String, factor: Double, tax: Double, @@ -53,22 +52,28 @@ object PkvHtmlTemplate { """.trimIndent() fun createPriceData( + medicationRequest: SyncedTaskData.MedicationRequest, + taskId: String, currency: String, totalBruttoAmount: Double, - items: List + items: List ): String { val (fees, articles) = items.partition { - (it.description as? ChargeableItem.Description.PZN)?.isSpecialPZN() ?: false + (it.description as? InvoiceData.ChargeableItem.Description.PZN)?.isSpecialPZN() ?: false } + val medication = joinMedicationInfo(medicationRequest) + return createPriceData( + medication, + taskId, currency = currency, totalBruttoAmount = totalBruttoAmount, articles = articles.map { val article = when (it.description) { - is ChargeableItem.Description.HMNR -> "HMKNR ${it.description.hmnr}" - is ChargeableItem.Description.PZN -> "PZN ${it.description.pzn}" - is ChargeableItem.Description.TA1 -> "TA1 ${it.description.ta1}" + is InvoiceData.ChargeableItem.Description.HMNR -> "HMKNR ${it.description.hmnr}" + is InvoiceData.ChargeableItem.Description.PZN -> "PZN ${it.description.pzn}" + is InvoiceData.ChargeableItem.Description.TA1 -> "TA1 ${it.description.ta1}" } createArticle( @@ -79,13 +84,13 @@ object PkvHtmlTemplate { ) }, fees = fees.map { - require(it.description is ChargeableItem.Description.PZN) - val article = when (SpecialPZN.valueOfPZN(it.description.pzn)) { - SpecialPZN.EmergencyServiceFee -> "Notdienstgebühr" - SpecialPZN.BTMFee -> "BTM-Gebühr" - SpecialPZN.TPrescriptionFee -> "T-Rezept Gebühr" - SpecialPZN.ProvisioningCosts -> "Beschaffungskosten" - SpecialPZN.DeliveryServiceCosts -> "Botendienst" + require(it.description is InvoiceData.ChargeableItem.Description.PZN) + val article = when (InvoiceData.SpecialPZN.valueOfPZN(it.description.pzn)) { + InvoiceData.SpecialPZN.EmergencyServiceFee -> "Notdienstgebühr" + InvoiceData.SpecialPZN.BTMFee -> "BTM-Gebühr" + InvoiceData.SpecialPZN.TPrescriptionFee -> "T-Rezept Gebühr" + InvoiceData.SpecialPZN.ProvisioningCosts -> "Beschaffungskosten" + InvoiceData.SpecialPZN.DeliveryServiceCosts -> "Botendienst" null -> error("wrong mapping") } createArticle( @@ -98,32 +103,101 @@ object PkvHtmlTemplate { ) } - fun createPriceData( + fun joinMedicationInfo(medicationRequest: SyncedTaskData.MedicationRequest?): String { + return when (val medication = medicationRequest?.medication) { + is SyncedTaskData.MedicationPZN -> + "${medicationRequest.quantity}x ${medication.text} / " + + "${medication.amount?.numerator?.value} " + + "${medication.amount?.numerator?.unit} " + + "${medication.normSizeCode} " + is SyncedTaskData.MedicationCompounding -> + "${medicationRequest.quantity}x ${medication.text} / " + + "${medication.amount?.numerator?.value} " + + "${medication.amount?.numerator?.unit} " + "${medication.form} " + is SyncedTaskData.MedicationIngredient -> + "${medicationRequest.quantity}x ${medication.text} / " + + "${medication.amount?.numerator?.value} " + + "${medication.amount?.numerator?.unit} " + "${medication.form} " + + "${medication.normSizeCode} " + is SyncedTaskData.MedicationFreeText -> "${medicationRequest.quantity}x ${medication.text}" + else -> "" + } + } + + private fun createPriceData( + requestMedication: String, + taskId: String, currency: String, totalBruttoAmount: Double, articles: List, fees: List ) = """
-
Kosten
-
-
Artikel
-
Anzahl
-
MwSt.
-
Bruttopreis in $currency
- ${articles.joinToString("")} -
Zusätzliche Gebühren
-
-
-
- ${fees.joinToString("")} -
Gesamtsumme
-
-
-
${totalBruttoAmount.currencyString()}
+
Kosten
+
+
Arzneimittel-ID: $taskId
+
+
+
+
$requestMedication
+
+
+
+
Abgabe
+
Anzahl
+
MwSt.
+
Bruttopreis in $currency
+ ${articles.joinToString("")} +
Zusätzliche Gebühren
+
+
+
+ ${fees.joinToString("")} +
Gesamtsumme
+
+
+
${totalBruttoAmount.currencyString()}
+
""".trimIndent() + + fun createHTML(invoice: InvoiceData.PKVInvoice): String { + val patient = createPatient( + patientName = invoice.patient.name ?: "", + patientAddress = invoice.patient.address?.joinToString() ?: "", + patientKVNR = invoice.patient.insuranceIdentifier ?: "" + ) + + val prescriber = createPrescriber( + prescriberName = invoice.practitioner.name ?: "", + prescriberAddress = invoice.practitionerOrganization.address?.joinToString() ?: "", + prescriberLANR = invoice.practitioner.practitionerIdentifier ?: "" + ) + val pharmacy = createOrganization( + organizationName = invoice.pharmacyOrganization.name ?: "", + organizationAddress = invoice.pharmacyOrganization.address?.joinToString() ?: "", + organizationIKNR = invoice.pharmacyOrganization.uniqueIdentifier + ) + + val priceData = createPriceData( + medicationRequest = invoice.medicationRequest, + taskId = invoice.taskId, + currency = invoice.invoice.currency, + totalBruttoAmount = invoice.invoice.totalBruttoAmount, + items = invoice.invoice.chargeableItems + ) + + return createPkvHtmlInvoiceTemplate( + patient = patient, + patientBirthdate = invoice.patient.birthdate?.formattedString() ?: "", + prescriber = prescriber, + prescribedOn = invoice.medicationRequest.authoredOn?.formattedString() ?: "", + pharmacy = pharmacy, + dispensedOn = invoice.whenHandedOver?.formattedString() ?: "", + priceData = priceData + ) + } } @Suppress("LongParameterList", "LongMethod") @@ -132,7 +206,7 @@ fun createPkvHtmlInvoiceTemplate( patientBirthdate: String, prescriber: String, prescribedOn: String, - organization: String, + pharmacy: String, dispensedOn: String, priceData: String ) = """ @@ -252,7 +326,7 @@ fun createPkvHtmlInvoiceTemplate(
Eingelöst
- $organization + $pharmacy
abgegeben am: @@ -268,4 +342,4 @@ fun createPkvHtmlInvoiceTemplate( """.trimIndent() -private fun Double.currencyString() = "%.2f".format(this) +fun Double.currencyString() = "%.2f".format(this) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/InvoiceData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/InvoiceData.kt new file mode 100644 index 00000000..68dc285c --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/model/InvoiceData.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.model + +import de.gematik.ti.erp.app.fhir.parser.FhirTemporal +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import kotlinx.datetime.Instant + +object InvoiceData { + + enum class SpecialPZN(val pzn: String) { + EmergencyServiceFee("02567018"), + BTMFee("02567001"), + TPrescriptionFee("06460688"), + ProvisioningCosts("09999637"), + DeliveryServiceCosts("06461110"); + + companion object { + fun isAnyOf(pzn: String): Boolean = values().any { it.pzn == pzn } + + fun valueOfPZN(pzn: String) = SpecialPZN.values().find { it.pzn == pzn } + } + } + + data class PKVInvoice( + val profileId: String, + val taskId: String, + val timestamp: Instant, + val pharmacyOrganization: SyncedTaskData.Organization, + val practitionerOrganization: SyncedTaskData.Organization, + val practitioner: SyncedTaskData.Practitioner, + var patient: SyncedTaskData.Patient, + val medicationRequest: SyncedTaskData.MedicationRequest, + val whenHandedOver: FhirTemporal?, + val invoice: Invoice + ) + + data class Invoice( + val totalAdditionalFee: Double, + val totalBruttoAmount: Double, + val currency: String, + val chargeableItems: List = listOf() + ) + + data class ChargeableItem(val description: Description, val factor: Double, val price: PriceComponent) { + sealed interface Description { + data class PZN(val pzn: String) : Description { + fun isSpecialPZN() = SpecialPZN.isAnyOf(pzn) + } + + data class TA1(val ta1: String) : Description + + data class HMNR(val hmnr: String) : Description + } + } + + data class PriceComponent(val value: Double, val tax: Double) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceLocalDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceLocalDataSource.kt new file mode 100644 index 00000000..1456031b --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceLocalDataSource.kt @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.repository + +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.DescriptionTypeV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 +import de.gematik.ti.erp.app.db.entities.v1.task.AccidentTypeV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationCategoryV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationProfileV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.tryWrite +import de.gematik.ti.erp.app.fhir.model.AccidentType +import de.gematik.ti.erp.app.fhir.model.MedicationCategory +import de.gematik.ti.erp.app.fhir.model.MedicationProfile +import de.gematik.ti.erp.app.fhir.model.extractBinary +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.fhir.model.extractInvoiceBundle +import de.gematik.ti.erp.app.fhir.model.extractInvoiceKBVAndErpPrBundle +import de.gematik.ti.erp.app.fhir.model.extractKBVBundle +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.prescription.repository.toMedication +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.ext.toRealmList +import io.realm.kotlin.query.max +import io.realm.kotlin.types.RealmInstant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.serialization.json.JsonElement + +class InvoiceLocalDataSource( + private val realm: Realm +) { + + fun latestInvoiceModifiedTimestamp(profileId: ProfileIdentifier): Flow = + realm.query("parent.id = $0", profileId) + .max("timestamp") + .asFlow() + .map { + it?.toInstant() + } + + private val mutex = Mutex() + + @Suppress("LongMethod") + suspend fun saveInvoice(profileId: ProfileIdentifier, bundle: JsonElement) = mutex.withLock { + realm.tryWrite { + queryFirst("id = $0", profileId)?.let { profile -> + + lateinit var invoiceEntity: PKVInvoiceEntityV1 + + extractInvoiceKBVAndErpPrBundle(bundle, process = { taskId, invoiceBundle, kbvBundle, erpPrBundle -> + extractInvoiceBundle( + invoiceBundle, + processDispense = { whenHandedOver -> + whenHandedOver + }, + processPharmacyAddress = { line, postalCode, city -> + AddressEntityV1().apply { + this.line1 = line?.getOrNull(0) ?: "" + this.line2 = line?.getOrNull(1) ?: "" + this.postalCodeAndCity = postalCode + city + } + }, + processPharmacy = { name, address, _, iknr, _, _ -> + OrganizationEntityV1().apply { + this.name = name + this.address = address + this.uniqueIdentifier = iknr + } + }, + processInvoice = { totalAdditionalFee, totalBruttoAmount, currency, items -> + InvoiceEntityV1().apply { + this.totalAdditionalFee = totalAdditionalFee + this.totalBruttoAmount = totalBruttoAmount + this.currency = currency + items.forEach { item -> + this.chargeableItems.add( + ChargeableItemV1().apply { + when (item.description) { + is InvoiceData.ChargeableItem.Description.PZN -> { + this.descriptionTypeV1 = DescriptionTypeV1.PZN + this.description = item.description.pzn + } + + is InvoiceData.ChargeableItem.Description.TA1 -> { + this.descriptionTypeV1 = DescriptionTypeV1.TA1 + this.description = item.description.ta1 + } + + is InvoiceData.ChargeableItem.Description.HMNR -> { + this.descriptionTypeV1 = DescriptionTypeV1.HMNR + this.description = item.description.hmnr + } + } + this.factor = item.factor + this.price = PriceComponentV1().apply { + this.value = item.price.value + this.tax = item.price.tax + } + } + ) + } + } + }, + save = { taskId, timeStamp, pharmacy, invoice, whenHandedOver -> + invoiceEntity = queryFirst("taskId = $0", taskId) ?: run { + copyToRealm(PKVInvoiceEntityV1()).also { + profile.invoices += it + } + } + + val kbvBinary = extractBinary(kbvBundle) ?: byteArrayOf() // Verordnung + val invoiceBinary = extractBinary(invoiceBundle) ?: byteArrayOf() // Abrechnung + val erpPrBinary = extractBinary(erpPrBundle) ?: byteArrayOf() // Quittung + + profile.apply { + this.invoices.add( + invoiceEntity.apply { + this.parent = profile + this.taskId = taskId + this.timestamp = timeStamp.toRealmInstant() + this.pharmacyOrganization = pharmacy + this.invoice = invoice + this.whenHandedOver = whenHandedOver + this.kbvBinary = kbvBinary + this.erpPrBinary = erpPrBinary + this.invoiceBinary = invoiceBinary + } + ) + } + } + ) + + extractKBVBundle( + kbvBundle, + processOrganization = { name, address, bsnr, iknr, phone, mail -> + OrganizationEntityV1().apply { + this.name = name + this.address = address + this.uniqueIdentifier = bsnr + this.phone = phone + this.mail = mail + } + }, + processPatient = { name, address, birthDate, insuranceIdentifier -> + PatientEntityV1().apply { + this.name = name + this.address = address + this.dateOfBirth = birthDate + this.insuranceIdentifier = insuranceIdentifier + } + }, + processPractitioner = { name, qualification, practitionerIdentifier -> + PractitionerEntityV1().apply { + this.name = name + this.qualification = qualification + this.practitionerIdentifier = practitionerIdentifier + } + }, + processInsuranceInformation = { name, statusCode -> + InsuranceInformationEntityV1().apply { + this.name = name + this.statusCode = statusCode + } + }, + processAddress = { line, postalCode, city -> + AddressEntityV1().apply { + this.line1 = line?.getOrNull(0) ?: "" + this.line2 = line?.getOrNull(1) ?: "" + this.postalCodeAndCity = listOfNotNull(postalCode, city).joinToString(" ") + } + }, + processQuantity = { value, unit -> + QuantityEntityV1().apply { + this.value = value + this.unit = unit + } + }, + processRatio = { numerator, denominator -> + RatioEntityV1().apply { + this.numerator = numerator + this.denominator = denominator + } + }, + processIngredient = { text, form, number, amount, strength -> + IngredientEntityV1().apply { + this.text = text + this.form = form + this.number = number + this.amount = amount + this.strength = strength + } + }, + processMedication = { text, + medicationProfile, + medicationCategory, + form, + amount, + vaccine, + manufacturingInstructions, + packaging, + normSizeCode, + uniqueIdentifier, + ingredients, + _, + _ -> + MedicationEntityV1().apply { + this.text = text ?: "" + this.medicationProfile = when (medicationProfile) { + MedicationProfile.PZN -> MedicationProfileV1.PZN + MedicationProfile.COMPOUNDING -> MedicationProfileV1.COMPOUNDING + MedicationProfile.INGREDIENT -> MedicationProfileV1.INGREDIENT + MedicationProfile.FREETEXT -> MedicationProfileV1.FREETEXT + else -> MedicationProfileV1.UNKNOWN + } + this.medicationCategory = when (medicationCategory) { + MedicationCategory.ARZNEI_UND_VERBAND_MITTEL -> + MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL + + MedicationCategory.BTM -> MedicationCategoryV1.BTM + MedicationCategory.AMVV -> MedicationCategoryV1.AMVV + MedicationCategory.SONSTIGES -> MedicationCategoryV1.SONSTIGES + else -> MedicationCategoryV1.UNKNOWN + } + this.form = form + this.amount = amount + this.vaccine = vaccine + this.manufacturingInstructions = manufacturingInstructions + this.packaging = packaging + this.normSizeCode = normSizeCode + this.uniqueIdentifier = uniqueIdentifier + this.ingredients = ingredients.toRealmList() + } + }, + processMultiplePrescriptionInfo = { indicator, numbering, start -> + MultiplePrescriptionInfoEntityV1().apply { + this.indicator = indicator + this.numbering = numbering + this.start = start?.value?.atStartOfDayIn(TimeZone.UTC)?.toRealmInstant() + } + }, + processMedicationRequest = { + authoredOn, + dateOfAccident, + location, + accidentType, + emergencyFee, + substitutionAllowed, + dosageInstruction, + quantity, + multiplePrescriptionInfo, + note, + bvg, + additionalFee + -> + MedicationRequestEntityV1().apply { + this.authoredOn = authoredOn + this.dateOfAccident = + dateOfAccident?.value?.atStartOfDayIn(TimeZone.UTC)?.toRealmInstant() + this.location = location + this.accidentType = when (accidentType) { + AccidentType.Unfall -> AccidentTypeV1.Unfall + AccidentType.Arbeitsunfall -> AccidentTypeV1.Arbeitsunfall + AccidentType.Berufskrankheit -> AccidentTypeV1.Berufskrankheit + AccidentType.None -> AccidentTypeV1.None + } + this.emergencyFee = emergencyFee + this.substitutionAllowed = substitutionAllowed + this.dosageInstruction = dosageInstruction + this.quantity = quantity + this.multiplePrescriptionInfo = multiplePrescriptionInfo + this.note = note + this.bvg = bvg + this.additionalFee = additionalFee + } + }, + savePVSIdentifier = {}, + save = { organization, + patient, + practitioner, + _, + medication, + medicationRequest -> + + invoiceEntity = queryFirst("taskId = $0", taskId) ?: run { + copyToRealm(PKVInvoiceEntityV1()).also { + profile.invoices += it + } + } + + invoiceEntity.apply { + this.parent = profile + this.practitionerOrganization = organization + this.patient = patient + this.practitioner = practitioner + this.medicationRequest = medicationRequest.apply { + this.medication = medication + } + } + } + ) + }) + } + } + } + + fun PKVInvoiceEntityV1.toPKVInvoice(): InvoiceData.PKVInvoice = + InvoiceData.PKVInvoice( + profileId = this.parent?.id ?: "", + timestamp = this.timestamp.toInstant(), + pharmacyOrganization = SyncedTaskData.Organization( + name = this.pharmacyOrganization?.name ?: "", + address = SyncedTaskData.Address( + line1 = this.pharmacyOrganization?.address?.line1 ?: "", + line2 = this.pharmacyOrganization?.address?.line2 ?: "", + postalCodeAndCity = this.pharmacyOrganization?.address?.postalCodeAndCity ?: "" + ) + ), + practitionerOrganization = SyncedTaskData.Organization( + name = this.practitionerOrganization?.name, + address = SyncedTaskData.Address( + line1 = this.practitionerOrganization?.address?.line1 ?: "", + line2 = this.practitionerOrganization?.address?.line2 ?: "", + postalCodeAndCity = this.practitionerOrganization?.address?.postalCodeAndCity ?: "" + ) + ), + practitioner = SyncedTaskData.Practitioner( + name = this.practitioner?.name, + qualification = this.practitioner?.qualification, + practitionerIdentifier = this.practitioner?.practitionerIdentifier + ), + patient = SyncedTaskData.Patient( + name = this.patient?.name, + address = this.patient?.address?.let { + SyncedTaskData.Address( + line1 = it.line1, + line2 = it.line2, + postalCodeAndCity = it.postalCodeAndCity + ) + }, + birthdate = this.patient?.dateOfBirth, + insuranceIdentifier = this.patient?.insuranceIdentifier + ), + medicationRequest = SyncedTaskData.MedicationRequest( + medication = this.medicationRequest?.medication?.toMedication(), + authoredOn = this.medicationRequest?.authoredOn, + dateOfAccident = this.medicationRequest?.dateOfAccident?.toInstant(), + location = this.medicationRequest?.location, + accidentType = when (this.medicationRequest?.accidentType) { + AccidentTypeV1.Unfall -> SyncedTaskData.AccidentType.Unfall + AccidentTypeV1.Arbeitsunfall -> SyncedTaskData.AccidentType.Arbeitsunfall + AccidentTypeV1.Berufskrankheit -> SyncedTaskData.AccidentType.Berufskrankheit + else -> SyncedTaskData.AccidentType.None + }, + emergencyFee = this.medicationRequest?.emergencyFee, + substitutionAllowed = this.medicationRequest?.substitutionAllowed ?: false, + dosageInstruction = this.medicationRequest?.dosageInstruction, + multiplePrescriptionInfo = SyncedTaskData.MultiplePrescriptionInfo( + indicator = this.medicationRequest?.multiplePrescriptionInfo?.indicator ?: false, + numbering = SyncedTaskData.Ratio( + numerator = SyncedTaskData.Quantity( + value = this.medicationRequest?.multiplePrescriptionInfo?.numbering?.numerator?.value ?: "", + unit = "" + ), + denominator = SyncedTaskData.Quantity( + value = + this.medicationRequest?.multiplePrescriptionInfo?.numbering?.denominator?.value ?: "", + unit = "" + ) + ), + start = this.medicationRequest?.multiplePrescriptionInfo?.start?.toInstant() + ), + additionalFee = when (this.medicationRequest?.additionalFee) { + "0" -> SyncedTaskData.AdditionalFee.NotExempt + "1" -> SyncedTaskData.AdditionalFee.Exempt + "2" -> SyncedTaskData.AdditionalFee.ArtificialFertilization + else -> SyncedTaskData.AdditionalFee.None + }, + quantity = this.medicationRequest?.quantity ?: 0, + note = this.medicationRequest?.note, + bvg = this.medicationRequest?.bvg + ), + taskId = this.taskId, + whenHandedOver = this.whenHandedOver, + invoice = InvoiceData.Invoice( + totalAdditionalFee = this.invoice?.totalAdditionalFee ?: 0.0, + totalBruttoAmount = this.invoice?.totalBruttoAmount ?: 0.0, + currency = this.invoice?.currency ?: "", + chargeableItems = this.invoice?.chargeableItems?.map { + InvoiceData.ChargeableItem( + description = when (it.descriptionTypeV1) { + DescriptionTypeV1.PZN -> InvoiceData.ChargeableItem.Description.PZN(it.description) + DescriptionTypeV1.HMNR -> InvoiceData.ChargeableItem.Description.HMNR(it.description) + DescriptionTypeV1.TA1 -> InvoiceData.ChargeableItem.Description.TA1(it.description) + }, + factor = it.factor, + price = InvoiceData.PriceComponent( + value = it.price?.value ?: 0.0, + tax = it.price?.tax ?: 0.0 + ) + ) + } ?: listOf() + ) + ) + + fun loadInvoices(profileId: ProfileIdentifier): Flow> = + realm.query("parent.id = $0", profileId) + .asFlow() + .map { invoices -> + invoices.list.map { invoice -> + invoice.toPKVInvoice() + } + } + + fun loadInvoiceAttachments(taskId: String) = + realm.queryFirst("taskId = $0", taskId)?.let { + listOf( + Triple("${taskId}_verordnung.ps7", "application/pkcs7-mime", it.kbvBinary), + Triple("${taskId}_abrechnung.ps7", "application/pkcs7-mime", it.invoiceBinary), + Triple("${taskId}_quittung.ps7", "application/pkcs7-mime", it.erpPrBinary) + ) + } + + fun loadInvoiceById(taskId: String): Flow = + realm.query("taskId = $0", taskId) + .first() + .asFlow() + .map { invoice -> + invoice.obj?.toPKVInvoice() + } + + suspend fun deleteInvoiceById(taskId: String) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.let { delete(it) } + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRemoteDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRemoteDataSource.kt new file mode 100644 index 00000000..60c107a0 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRemoteDataSource.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.repository + +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.api.ErpService +import de.gematik.ti.erp.app.api.safeApiCall +import de.gematik.ti.erp.app.api.safeApiCallRaw +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import java.net.HttpURLConnection + +class InvoiceRemoteDataSource( + private val service: ErpService +) { + suspend fun getChargeItems( + profileId: ProfileIdentifier, + lastUpdated: String?, + count: Int? = null, + offset: Int? = null + ) = safeApiCall( + errorMessage = "Error getting all chargeItems" + ) { + service.getChargeItems( + profileId = profileId, + lastUpdated = lastUpdated, + count = count, + offset = offset + ) + } + + suspend fun getChargeItemBundleById( + profileId: ProfileIdentifier, + taskID: String + ) = safeApiCall( + errorMessage = "error while downloading ChargeItem for $taskID" + ) { service.getChargeItemBundleById(profileId = profileId, id = taskID) } + + suspend fun deleteChargeItemById( + profileId: ProfileIdentifier, + taskId: String + ) = safeApiCallRaw( + errorMessage = "Error delete charge item" + ) { + val response = service.deleteChargeItemById( + profileId = profileId, + id = taskId + ) + if (response.code() == HttpURLConnection.HTTP_NO_CONTENT) { + Result.success(Unit) + } else { + Result.failure( + ApiCallException( + "Expected no content but received: ${response.code()} ${response.message()}", + response + ) + ) + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepository.kt new file mode 100644 index 00000000..a3590c8a --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepository.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.api.ResourcePaging +import de.gematik.ti.erp.app.fhir.model.extractTaskIdsFromChargeItemBundle +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.datetime.Instant +import kotlinx.serialization.json.JsonElement +import java.net.HttpURLConnection + +private const val InvoiceMaxPageSize = 25 + +class InvoiceRepository( + private val remoteDataSource: InvoiceRemoteDataSource, + private val localDataSource: InvoiceLocalDataSource, + private val dispatchers: DispatchProvider +) : ResourcePaging(dispatchers, InvoiceMaxPageSize, maxPages = 1) { + + suspend fun downloadInvoices(profileId: ProfileIdentifier) = downloadPaged(profileId) { prev: Int?, next: Int -> + (prev ?: 0) + next + }.map { + it ?: 0 + } + + fun invoices(profileId: ProfileIdentifier) = + localDataSource.loadInvoices(profileId).flowOn(dispatchers.IO) + + fun invoiceById(taskId: String) = + localDataSource.loadInvoiceById(taskId).flowOn(dispatchers.IO) + + suspend fun saveInvoice(profileId: ProfileIdentifier, bundle: JsonElement) { + localDataSource.saveInvoice(profileId, bundle) + } + + override val tag: String = "InvoiceRepository" + + override suspend fun downloadResource( + profileId: ProfileIdentifier, + timestamp: String?, + count: Int? + ): Result> = + remoteDataSource.getChargeItems( + profileId = profileId, + lastUpdated = timestamp, + count = count + ).mapCatching { fhirBundle -> + withContext(dispatchers.IO) { + val (total, taskIds) = extractTaskIdsFromChargeItemBundle(fhirBundle) + + supervisorScope { + val results = taskIds.map { taskId -> + async { + downloadInvoiceWithBundle(taskId = taskId, profileId = profileId) + } + }.awaitAll() + + // return number of bundles saved to db + ResourceResult(total, results.size) + } + } + } + private suspend fun downloadInvoiceWithBundle( + taskId: String, + profileId: ProfileIdentifier + ) = withContext(dispatchers.IO) { + remoteDataSource.getChargeItemBundleById(profileId, taskId).mapCatching { bundle -> + requireNotNull(localDataSource.saveInvoice(profileId, bundle)) + } + } + + suspend fun deleteInvoiceById( + taskId: String, + profileId: ProfileIdentifier + ) = withContext(dispatchers.IO) { + val result = remoteDataSource.deleteChargeItemById(profileId, taskId) + .onSuccess { + localDataSource.deleteInvoiceById(taskId) + }.onFailure { + if (it is ApiCallException) { + when (it.response.code()) { + HttpURLConnection.HTTP_NOT_FOUND, + HttpURLConnection.HTTP_GONE -> + localDataSource.deleteInvoiceById(taskId) + } + } + } + result + } + + fun loadInvoiceAttachments(taskId: String) = + localDataSource.loadInvoiceAttachments(taskId) + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + localDataSource.latestInvoiceModifiedTimestamp(profileId).first() +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCase.kt new file mode 100644 index 00000000..862a3aaa --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/invoice/usecase/InvoiceUseCase.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import de.gematik.ti.erp.app.invoice.repository.InvoiceRepository +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +class InvoiceUseCase( + private val invoiceRepository: InvoiceRepository, + private val dispatchers: DispatchProvider +) { + fun invoicesFlow(profileId: ProfileIdentifier): Flow> = + invoiceRepository.invoices(profileId).flowOn(dispatchers.IO) + + fun invoices(profileId: ProfileIdentifier): Flow>> = + invoicesFlow(profileId).map { invoices -> + invoices.sortedWith( + compareByDescending { + it.timestamp + } + ).groupBy { + it.timestamp.toLocalDateTime(TimeZone.currentSystemDefault()).year + } + } + + suspend fun deleteInvoice(profileId: ProfileIdentifier, taskId: String): Result { + return invoiceRepository.deleteInvoiceById(profileId = profileId, taskId = taskId) + } + + private class Request( + val resultChannel: Channel>, + val forProfileId: ProfileIdentifier + ) + + private val scope = CoroutineScope(dispatchers.IO) + + private val requestChannel = + Channel(onUndeliveredElement = { it.resultChannel.close(CancellationException()) }) + + private val _refreshInProgress = MutableStateFlow(false) + val refreshInProgress: StateFlow + get() = _refreshInProgress + + init { + scope.launch { + for (request in requestChannel) { + _refreshInProgress.value = true + Napier.d { "Start refreshing as per request" } + + val profileId = request.forProfileId + + val result = runCatching { + val nrOfNewInvoices = invoiceRepository.downloadInvoices(profileId).getOrThrow() + nrOfNewInvoices + } + request.resultChannel.trySend(result) + + Napier.d { "Finished refreshing" } + _refreshInProgress.value = false + } + } + } + + private suspend fun download(profileId: ProfileIdentifier): Result { + val resultChannel = Channel>() + try { + requestChannel.send(Request(resultChannel = resultChannel, forProfileId = profileId)) + + return resultChannel.receive() + } catch (cancellation: CancellationException) { + Napier.d { "Cancelled waiting for result of refresh request" } + withContext(NonCancellable) { + resultChannel.close(cancellation) + } + throw cancellation + } + } + + fun downloadInvoices(profileId: ProfileIdentifier): Flow = + flow { + emit(download(profileId).getOrThrow()) + } + + fun loadAttachments(taskId: String) = + invoiceRepository.loadInvoiceAttachments(taskId) + + fun invoiceById(taskId: String): Flow = + invoiceRepository.invoiceById(taskId) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt index 64bd0fbe..000d6eef 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt @@ -121,7 +121,7 @@ object SyncedTaskData { fun redeemedOn() = if (status == TaskStatus.Completed) { - medicationDispenses.firstOrNull()?.whenHandedOver ?: lastModified + medicationDispenses.firstOrNull()?.whenHandedOver?.toInstant() ?: lastModified } else { null } @@ -230,6 +230,7 @@ object SyncedTaskData { data class MedicationRequest( val medication: Medication? = null, + val authoredOn: FhirTemporal? = null, val dateOfAccident: Instant? = null, val accidentType: AccidentType = AccidentType.None, val location: String? = null, @@ -263,7 +264,7 @@ object SyncedTaskData { val wasSubstituted: Boolean, val dosageInstruction: String?, val performer: String, - val whenHandedOver: Instant + val whenHandedOver: FhirTemporal? ) enum class MedicationCategory { diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/TaskLocalDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/TaskLocalDataSource.kt index edfb8651..7a1f3192 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/TaskLocalDataSource.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/TaskLocalDataSource.kt @@ -246,7 +246,9 @@ class TaskLocalDataSource( this.start = start?.value?.atStartOfDayIn(TimeZone.UTC)?.toRealmInstant() } }, - processMedicationRequest = { dateOfAccident, + processMedicationRequest = { + authoredOn, + dateOfAccident, location, accidentType, emergencyFee, @@ -259,6 +261,7 @@ class TaskLocalDataSource( additionalFee -> MedicationRequestEntityV1().apply { + this.authoredOn = authoredOn this.dateOfAccident = dateOfAccident?.value?.atStartOfDayIn(TimeZone.UTC)?.toRealmInstant() this.location = location @@ -403,7 +406,7 @@ class TaskLocalDataSource( this.wasSubstituted = wasSubstituted this.dosageInstruction = dosageInstruction this.performer = performer - this.whenHandedOver = whenHandedOver.value.atStartOfDayIn(TimeZone.UTC).toRealmInstant() + this.handedOverOn = whenHandedOver } } } @@ -518,15 +521,14 @@ fun SyncedTaskEntityV1.toSyncedTask(): SyncedTaskData.SyncedTask = wasSubstituted = medicationDispense.wasSubstituted, dosageInstruction = medicationDispense.dosageInstruction, performer = medicationDispense.performer, - whenHandedOver = medicationDispense.whenHandedOver.toInstant() + whenHandedOver = medicationDispense.handedOverOn ) }, communications = this.communications.mapNotNull { communication -> communication.toCommunication() } ) - -private fun MedicationEntityV1?.toMedication(): SyncedTaskData.Medication? = +fun MedicationEntityV1?.toMedication(): SyncedTaskData.Medication? = when (this?.medicationProfile) { MedicationProfileV1.PZN -> SyncedTaskData.MedicationPZN( uniqueIdentifier = this.uniqueIdentifier ?: "", diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventRemoteDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventRemoteDataSource.kt index 9575ad89..37526142 100644 --- a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventRemoteDataSource.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventRemoteDataSource.kt @@ -18,6 +18,7 @@ package de.gematik.ti.erp.app.protocol.repository +import androidx.compose.ui.text.intl.Locale import de.gematik.ti.erp.app.api.ErpService import de.gematik.ti.erp.app.api.safeApiCall import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier @@ -35,6 +36,7 @@ class AuditEventRemoteDataSource( ) { service.getAuditEvents( profileId = profileId, + language = Locale.current.language, lastKnownDate = lastKnownUpdate, count = count, offset = offset diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt index 9a33458d..d5f39970 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt @@ -20,6 +20,10 @@ package de.gematik.ti.erp.app.db.entities.v1 import de.gematik.ti.erp.app.db.TestDB import de.gematik.ti.erp.app.db.entities.deleteAll +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 @@ -73,7 +77,11 @@ class SyncedTaskEntityV1Test : TestDB() { IngredientEntityV1::class, QuantityEntityV1::class, RatioEntityV1::class, - MultiplePrescriptionInfoEntityV1::class + MultiplePrescriptionInfoEntityV1::class, + PKVInvoiceEntityV1::class, + InvoiceEntityV1::class, + ChargeableItemV1::class, + PriceComponentV1::class ) ) .schemaVersion(0) diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapperTest.kt new file mode 100644 index 00000000..55e7b9e3 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/InvoiceMapperTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asFhirTemporal +import de.gematik.ti.erp.app.invoice.model.InvoiceData +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +enum class PKVReturnType { + InvoiceBundle, Invoice, Pharmacy, PharmacyAddress, Dispense +} + +class InvoiceMapperTest { + @Test + fun `process pkv bundle version 1_1`() { + val bundle = Json.parseToJsonElement(pkvAbgabedatenJson_vers_1_1) + + extractInvoiceKBVAndErpPrBundle(bundle, process = { taskId, invoiceBundle, kbvBundle, erpPrBundle -> + + assertEquals("200.000.001.205.203.40", taskId) + val erpBinary = extractBinary(erpPrBundle) + val invoiceBinary = extractBinary(invoiceBundle) + val kbvBinary = extractBinary(kbvBundle) + + assertEquals( + "MIIUmwYJKoZIhvcNAQcCoIIUjDCCFIgCAQUxDTALBglghkgBZQMEAgEwggp1Bgkqh", + erpBinary?.decodeToString() + ) + + assertEquals( + "MIIuswYJKoZIhvcNAQcCoIIupDCCLqACAQExDTALBglghkgBZQMEAgEwghlq", + invoiceBinary?.decodeToString() + ) + + assertEquals( + "MII01wYJKoZIhvcNAQcCoII0yDCCNMQCAQUxDTALBglghkgBZQM", + kbvBinary?.decodeToString() + ) + + extractInvoiceBundle( + invoiceBundle, + processInvoice = { totalAdditionalFee, totalBruttoAmount, currency, items -> + assertEquals(217.69, totalAdditionalFee) + assertEquals(534.2, totalBruttoAmount) + assertEquals("EUR", currency) + + assertEquals( + InvoiceData.ChargeableItem( + InvoiceData.ChargeableItem.Description.PZN("83251243"), + 1.0, + InvoiceData.PriceComponent(6.23, 11.06) + ), + items[0] + ) + assertEquals( + false, + (items[0].description as InvoiceData.ChargeableItem.Description.PZN).isSpecialPZN() + ) + + assertEquals( + InvoiceData.ChargeableItem( + InvoiceData.ChargeableItem.Description.PZN("22894670"), + 1.0, + InvoiceData.PriceComponent(527.97, 11.06) + ), + items[1] + ) + assertEquals( + false, + (items[1].description as InvoiceData.ChargeableItem.Description.PZN).isSpecialPZN() + ) + + PKVReturnType.Invoice + }, + processDispense = { whenHandedOver -> + assertEquals(LocalDate.parse("2023-02-17").asFhirTemporal(), whenHandedOver) + + PKVReturnType.Dispense + }, + processPharmacyAddress = { line, postalCode, city -> + assertEquals(listOf("Görresstr. 789"), line) + assertEquals("48480", postalCode) + assertEquals("Süd Eniefeld", city) + + PKVReturnType.PharmacyAddress + }, + processPharmacy = { name, address, bsnr, iknr, phone, mail -> + assertEquals("Apotheke Crystal Claire Waters", name) + assertEquals(PKVReturnType.PharmacyAddress, address) + assertEquals(null, bsnr) + assertEquals("833940499", iknr) + assertEquals(null, phone) + assertEquals(null, mail) + + PKVReturnType.Pharmacy + }, + save = { taskId, _, pharmacy, invoice, dispense -> + assertEquals("200.000.001.205.203.40", taskId) + assertEquals(PKVReturnType.Pharmacy, pharmacy) + assertEquals(PKVReturnType.Invoice, invoice) + assertEquals(PKVReturnType.Dispense, dispense) + + PKVReturnType.InvoiceBundle + } + ) + }) + } + + @Test + fun `extract task id from chargeItem bundle`() { + val bundle = Json.parseToJsonElement(charge_item_bundle_version_1_2) + val (bundleTotal, taskIds) = extractTaskIdsFromChargeItemBundle(bundle) + assertEquals(2, bundleTotal) + assertEquals("200.086.824.605.539.20", taskIds[0]) + assertEquals("200.086.824.605.539.20", taskIds[1]) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt index 10d15bb3..5a2bc50f 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt @@ -125,7 +125,7 @@ class MedicationDispenseMapperTest { assertEquals(false, wasSubstituted) assertEquals(null, dosageInstruction) assertEquals("3-SMC-B-Testkarte-883110000116873", performer) - assertEquals(LocalDate.parse("2022-07-12"), whenHandedOver.value) + assertEquals("2022-07-12", whenHandedOver.formattedString()) ReturnType.MedicationDispense } ) diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PKVMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PKVMapperTest.kt deleted file mode 100644 index f0c77fdf..00000000 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PKVMapperTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2023 gematik GmbH - * - * 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 de.gematik.ti.erp.app.fhir.model - -import de.gematik.ti.erp.app.fhir.parser.asFhirTemporal -import kotlinx.datetime.LocalDate -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals - -enum class PKVReturnType { - InvoiceBundle, Invoice, Pharmacy, PharmacyAddress, Dispense -} - -class PKVMapperTest { - @Test - fun `process pkv bundle version 1_1`() { - val bundle = Json.parseToJsonElement(pkvAbgabedatenJson_vers_1_1) - val result = extractPKVInvoiceBundle( - bundle, - processInvoice = { totalAdditionalFee, totalBruttoAmount, currency, items -> - assertEquals(0.0, totalAdditionalFee) - assertEquals(51.48, totalBruttoAmount) - assertEquals("EUR", currency) - - assertEquals( - ChargeableItem(ChargeableItem.Description.PZN("11514676"), 2.0, PriceComponent(48.98, 19.0)), - items[0] - ) - assertEquals(false, (items[0].description as ChargeableItem.Description.PZN).isSpecialPZN()) - - assertEquals( - ChargeableItem(ChargeableItem.Description.PZN("02567018"), 1.0, PriceComponent(2.50, 19.0)), - items[1] - ) - assertEquals(true, (items[1].description as ChargeableItem.Description.PZN).isSpecialPZN()) - - PKVReturnType.Invoice - }, - processDispense = { whenHandedOver -> - assertEquals(LocalDate.parse("2022-03-25").asFhirTemporal(), whenHandedOver) - - PKVReturnType.Dispense - }, - processPharmacyAddress = { line, postalCode, city -> - assertEquals(listOf("Taunusstraße 89"), line) - assertEquals("63225", postalCode) - assertEquals("Langen", city) - - PKVReturnType.PharmacyAddress - }, - processPharmacy = { name, address, bsnr, iknr, phone, mail -> - assertEquals("Adler-Apotheke", name) - assertEquals(PKVReturnType.PharmacyAddress, address) - assertEquals(null, bsnr) - assertEquals("123456789", iknr) - assertEquals(null, phone) - assertEquals(null, mail) - - PKVReturnType.Pharmacy - }, - save = { pharmacy, invoice, dispense -> - assertEquals(PKVReturnType.Pharmacy, pharmacy) - assertEquals(PKVReturnType.Invoice, invoice) - assertEquals(PKVReturnType.Dispense, dispense) - - PKVReturnType.InvoiceBundle - } - ) - - assertEquals(PKVReturnType.InvoiceBundle, result) - } -} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt index 104ae383..c7e382d0 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt @@ -26,6 +26,7 @@ import kotlin.test.Test import kotlin.test.assertEquals private val testBundle by lazy { File("$ResourceBasePath/pharmacy_result_bundle.json").readText() } +private val testBundleBinaries by lazy { File("$ResourceBasePath/fhir/pharmacy_binary.json").readText() } class PharmacyMapperTest { private val openingTimeA = OpeningTime(LocalTime.parse("08:00:00"), LocalTime.parse("12:00:00")) @@ -95,4 +96,10 @@ class PharmacyMapperTest { assertEquals(expected, pharmacies[0]) } + + @Test + fun `extract certificate`() { + val result = extractBinaryCertificateAsBase64(Json.parseToJsonElement(testBundleBinaries)) + assertEquals("MIIFlDCCBHygAwwKGi44czSg==", result) + } } diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion102Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion102Test.kt index 249ea57b..ec1c07dd 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion102Test.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion102Test.kt @@ -258,17 +258,20 @@ class RessourceMapperVersion102Test { assertEquals(FhirTemporal.LocalDate(LocalDate.parse("2022-08-17")), start) ReturnType.MultiplePrescriptionInfo }, - processMedicationRequest = { dateOfAccident, - location, - accidentType, - emergencyFee, - substitutionAllowed, - dosageInstruction, - quantity, - multiplePrescriptionInfo, - note, - bvg, - additionalFee -> + processMedicationRequest = { + authoredOn, + dateOfAccident, + location, + accidentType, + emergencyFee, + substitutionAllowed, + dosageInstruction, + quantity, + multiplePrescriptionInfo, + note, + bvg, + additionalFee -> + assertEquals(null, authoredOn) assertEquals(FhirTemporal.LocalDate(LocalDate.parse("2022-06-29")), dateOfAccident) assertEquals("Dummy-Betrieb", location) assertEquals(AccidentType.Arbeitsunfall, accidentType) diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion110Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion110Test.kt index 69b07ea9..ce90dc22 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion110Test.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/RessourceMapperVersion110Test.kt @@ -226,17 +226,20 @@ class RessourceMapperVersion110Test { assertEquals(LocalDate.parse("2022-05-20").asFhirTemporal(), start) ReturnType.MultiplePrescriptionInfo }, - processMedicationRequest = { dateOfAccident, - location, - accidentType, - emergencyFee, - substitutionAllowed, - dosageInstruction, - quantity, - multiplePrescriptionInfo, - note, - bvg, - additionalFee -> + processMedicationRequest = { + authoredOn, + dateOfAccident, + location, + accidentType, + emergencyFee, + substitutionAllowed, + dosageInstruction, + quantity, + multiplePrescriptionInfo, + note, + bvg, + additionalFee -> + assertEquals(null, authoredOn) assertEquals(null, dateOfAccident) assertEquals(null, location) assertEquals(AccidentType.None, accidentType) diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestData.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestData.kt index 7931ff8a..f2878965 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestData.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestData.kt @@ -128,6 +128,10 @@ val task_bundle_version_1_2 by lazy { File("$ResourceBasePath/fhir/task_bundle_vers_1_2.json").readText() } +val charge_item_bundle_version_1_2 by lazy { + File("$ResourceBasePath/fhir/charge_item_bundle_vers_1_2.json").readText() +} + val pkvAbgabedatenJson_vers_1_1 by lazy { - File("$ResourceBasePath/fhir/pkv_abgabedaten_1_1.json").readText() + File("$ResourceBasePath/fhir/charge_item_by_id_bundle.json").readText() } diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt index d3476a19..b06a2be2 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt @@ -32,6 +32,10 @@ import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 @@ -152,7 +156,11 @@ class CommonIdpRepositoryTest : TestDB() { PasswordEntityV1::class, ShippingContactEntityV1::class, PharmacySearchEntityV1::class, - MultiplePrescriptionInfoEntityV1::class + MultiplePrescriptionInfoEntityV1::class, + PKVInvoiceEntityV1::class, + InvoiceEntityV1::class, + ChargeableItemV1::class, + PriceComponentV1::class ) ) .schemaVersion(ACTUAL_SCHEMA_VERSION) diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepositoryTest.kt new file mode 100644 index 00000000..9445a3e7 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/invoice/repository/InvoiceRepositoryTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2023 gematik GmbH + * + * 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 de.gematik.ti.erp.app.invoice.repository + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.api.ErpService +import de.gematik.ti.erp.app.db.ACTUAL_SCHEMA_VERSION +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.AuditEventEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpConfigurationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.fhir.model.pkvAbgabedatenJson_vers_1_1 +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import org.junit.Rule +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +class InvoiceRepositoryTest : TestDB() { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + lateinit var invoiceLocalDataSource: InvoiceLocalDataSource + lateinit var invoiceRemoteDataSource: InvoiceRemoteDataSource + lateinit var invoiceRepository: InvoiceRepository + lateinit var profileRepository: ProfilesRepository + + lateinit var realm: Realm + + @MockK + lateinit var erpService: ErpService + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this) + realm = Realm.open( + RealmConfiguration.Builder( + schema = setOf( + ProfileEntityV1::class, + SyncedTaskEntityV1::class, + OrganizationEntityV1::class, + PractitionerEntityV1::class, + PatientEntityV1::class, + InsuranceInformationEntityV1::class, + MedicationRequestEntityV1::class, + MedicationDispenseEntityV1::class, + CommunicationEntityV1::class, + AddressEntityV1::class, + MedicationEntityV1::class, + IngredientEntityV1::class, + RatioEntityV1::class, + QuantityEntityV1::class, + ScannedTaskEntityV1::class, + IdpAuthenticationDataEntityV1::class, + IdpConfigurationEntityV1::class, + AuditEventEntityV1::class, + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + ShippingContactEntityV1::class, + PharmacySearchEntityV1::class, + MultiplePrescriptionInfoEntityV1::class, + PKVInvoiceEntityV1::class, + InvoiceEntityV1::class, + ChargeableItemV1::class, + PriceComponentV1::class + ) + ) + .schemaVersion(ACTUAL_SCHEMA_VERSION) + .directory(tempDBPath) + .build() + ) + + invoiceLocalDataSource = InvoiceLocalDataSource(realm) + invoiceRemoteDataSource = InvoiceRemoteDataSource(erpService) + invoiceRepository = InvoiceRepository( + invoiceRemoteDataSource, + invoiceLocalDataSource, + coroutineRule.dispatchers + ) + profileRepository = ProfilesRepository(coroutineRule.dispatchers, realm) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `save invoices and load invoice`() { + val chargeItemByIdBundle = Json.parseToJsonElement(pkvAbgabedatenJson_vers_1_1) + + runTest { + profileRepository.saveProfile("test", true) + val testProfileId = + profileRepository.profiles().first()[0].id + + invoiceRepository.saveInvoice(testProfileId, chargeItemByIdBundle) + val invoice = invoiceRepository.invoices(testProfileId).first()[0] + + assertEquals("200.000.001.205.203.40", invoice.taskId) + assertEquals(534.2, invoice.invoice.totalBruttoAmount) + assertEquals(Instant.parse("2023-02-17T14:07:45.077Z"), invoice.timestamp) + + val attachments = invoiceRepository.loadInvoiceAttachments(invoice.taskId) + + assertEquals("200.000.001.205.203.40_verordnung.ps7", attachments?.get(0)?.first) + assertEquals("application/pkcs7-mime", attachments?.get(0)?.second) + + assertEquals("200.000.001.205.203.40_abrechnung.ps7", attachments?.get(1)?.first) + assertEquals("application/pkcs7-mime", attachments?.get(1)?.second) + + assertEquals("200.000.001.205.203.40_quittung.ps7", attachments?.get(2)?.first) + assertEquals("application/pkcs7-mime", attachments?.get(2)?.second) + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt index 79e8c3d9..b5fb0dcb 100644 --- a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt @@ -29,6 +29,10 @@ import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.ChargeableItemV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.InvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PKVInvoiceEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.invoice.PriceComponentV1 import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 @@ -100,7 +104,11 @@ class ProfilesRepositoryTest : TestDB() { PasswordEntityV1::class, ShippingContactEntityV1::class, PharmacySearchEntityV1::class, - MultiplePrescriptionInfoEntityV1::class + MultiplePrescriptionInfoEntityV1::class, + PKVInvoiceEntityV1::class, + InvoiceEntityV1::class, + ChargeableItemV1::class, + PriceComponentV1::class ) ) .schemaVersion(ACTUAL_SCHEMA_VERSION) diff --git a/common/src/commonTest/resources/fhir/charge_item_bundle_vers_1_2.json b/common/src/commonTest/resources/fhir/charge_item_bundle_vers_1_2.json new file mode 100644 index 00000000..9fe8ad41 --- /dev/null +++ b/common/src/commonTest/resources/fhir/charge_item_bundle_vers_1_2.json @@ -0,0 +1,174 @@ +{ + "resourceType": "Bundle", + "id": "200e3c55-b154-4335-a0ec-65addd39a3b6", + "meta": { + "lastUpdated": "2021-09-02T11:38:42.557+00:00" + }, + "type": "searchset", + "total": 2, + "entry": [ + { + "fullUrl": "http://hapi.fhir.org/baseR4/ChargeItem/abc825bc-bc30-45f8-b109-1b343fff5c45", + "resource": { + "resourceType": "ChargeItem", + "id": "abc825bc-bc30-45f8-b109-1b343fff5c45", + "meta": { + "profile": [ + "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_PR_ChargeItem|1.0" + ] + }, + "status": "billable", + "extension": [ + { + "url": "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_EX_MarkingFlag", + "extension": [ + { + "url": "insuranceProvider", + "valueBoolean": false + }, + { + "url": "subsidy", + "valueBoolean": false + }, + { + "url": "taxOffice", + "valueBoolean": false + } + ] + } + ], + "enterer": { + "identifier": { + "system": "https://gematik.de/fhir/sid/telematik-id", + "value": "3-15.2.1456789123.191" + } + }, + "identifier": [ + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.086.824.605.539.20" + }, + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_AccessCode", + "value": "777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea" + } + ], + "code": { + "coding": [ + { + "code": "not-applicable", + "system": "http://terminology.hl7.org/CodeSystem/data-absent-reason" + } + ] + }, + "subject": { + "identifier": { + "system": "http://fhir.de/sid/gkv/kvid-10", + "value": "X234567890", + "assigner": { + "display": "Name einer privaten Krankenversicherung" + } + } + }, + "enteredDate": "2021-06-01T07:13:00+05:00", + "supportingInformation": [ + { + "reference": "Bundle/0428d416-149e-48a4-977c-394887b3d85c", + "display": "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle" + }, + { + "reference": "Bundle/72bd741c-7ad8-41d8-97c3-9aabbdd0f5b4", + "display": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle" + }, + { + "reference": "Bundle/074fb409-2a14-4e06-8f9b-04fbc0e3bd41", + "display": "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Bundle" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "http://hapi.fhir.org/baseR4/ChargeItem/der124bc-bc30-45f8-b109-4h474wer2h89", + "resource": { + "resourceType": "ChargeItem", + "id": "der124bc-bc30-45f8-b109-4h474wer2h89", + "meta": { + "profile": [ + "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_PR_ChargeItem|1.0" + ] + }, + "status": "billable", + "extension": [ + { + "url": "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_EX_MarkingFlag", + "extension": [ + { + "url": "insuranceProvider", + "valueBoolean": false + }, + { + "url": "subsidy", + "valueBoolean": false + }, + { + "url": "taxOffice", + "valueBoolean": false + } + ] + } + ], + "enterer": { + "identifier": { + "system": "https://gematik.de/fhir/sid/telematik-id", + "value": "3-15.2.1456789123.191" + } + }, + "identifier": [ + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.086.824.605.539.20" + }, + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_AccessCode", + "value": "888bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea" + } + ], + "code": { + "coding": [ + { + "code": "not-applicable", + "system": "http://terminology.hl7.org/CodeSystem/data-absent-reason" + } + ] + }, + "subject": { + "identifier": { + "system": "http://fhir.de/sid/gkv/kvid-10", + "value": "X234567890", + "assigner": { + "display": "Name einer privaten Krankenversicherung" + } + } + }, + "enteredDate": "2021-06-01T07:13:00+05:00", + "supportingInformation": [ + { + "reference": "Bundle/0428d416-149e-48a4-977c-394887b3d85c", + "display": "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle" + }, + { + "reference": "Bundle/72bd741c-7ad8-41d8-97c3-9aabbdd0f5b4", + "display": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle" + }, + { + "reference": "Bundle/2fbc0103-1d1b-4be6-8ed8-6faf87bcc09b", + "display": "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Bundle" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/charge_item_by_id_bundle.json b/common/src/commonTest/resources/fhir/charge_item_by_id_bundle.json new file mode 100644 index 00000000..552eb7dd --- /dev/null +++ b/common/src/commonTest/resources/fhir/charge_item_by_id_bundle.json @@ -0,0 +1,1076 @@ +{ + "resourceType": "Bundle", + "id": "658d213d-523b-4a24-bbdb-f237611ead2d", + "type": "collection", + "timestamp": "2023-02-17T14:07:47.710+00:00", + "entry": [ + { + "fullUrl": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/ChargeItem/200.000.001.205.203.40", + "resource": { + "resourceType": "ChargeItem", + "id": "200.000.001.205.203.40", + "meta": { + "profile": [ + "https://gematik.de/fhir/erpchrg/StructureDefinition/GEM_ERPCHRG_PR_ChargeItem|1.0" + ] + }, + "identifier": [ + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.000.001.205.203.40" + }, + { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_AccessCode", + "value": "feaf93c400be820a1981250a29d529e3de9a5a3054049d58f133ea13e00d36b0" + } + ], + "status": "billable", + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/data-absent-reason", + "code": "not-applicable" + } + ] + }, + "subject": { + "identifier": { + "system": "http://fhir.de/sid/pkv/kvid-10", + "value": "X110465770" + } + }, + "enterer": { + "identifier": { + "system": "https://gematik.de/fhir/sid/telematik-id", + "value": "3-SMC-B-Testkarte-883110000116873" + } + }, + "enteredDate": "2023-02-17T14:07:46.964+00:00", + "supportingInformation": [ + { + "reference": "Bundle/775157da-afc8-4248-b90b-a32163895323", + "display": "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle" + }, + { + "reference": "Bundle/a2442313-18da-4051-b355-42a47d9f823a", + "display": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle" + }, + { + "reference": "Bundle/c8d36312-0000-0000-0003-000000000000", + "display": "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Bundle" + } + ] + } + }, + { + "fullUrl": "urn:uuid:a2442313-18da-4051-b355-42a47d9f823a", + "resource": { + "resourceType": "Bundle", + "meta": { + "lastUpdated": "2023-02-17T15:07:45.077+01:00", + "profile": [ + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenBundle|1.1" + ] + }, + "identifier": { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.000.001.205.203.40" + }, + "type": "document", + "timestamp": "2023-02-17T15:07:45.077+01:00", + "entry": [ + { + "fullUrl": "urn:uuid:f67f6885-c527-4198-a44a-d5bef2fda5b9", + "resource": { + "resourceType": "Composition", + "id": "f67f6885-c527-4198-a44a-d5bef2fda5b9", + "meta": { + "profile": [ + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-AbgabedatenComposition|1.1" + ] + }, + "status": "final", + "type": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-CS-ERP-CompositionTypes", + "code": "ERezeptAbgabedaten" + } + ] + }, + "date": "2023-02-17T15:07:45+01:00", + "author": [ + { + "reference": "urn:uuid:623e785c-0f6d-4db9-8488-9809b8493537" + } + ], + "title": "ERezeptAbgabedaten", + "section": [ + { + "title": "Apotheke", + "entry": [ + { + "reference": "urn:uuid:623e785c-0f6d-4db9-8488-9809b8493537" + } + ] + }, + { + "title": "Abgabeinformationen", + "entry": [ + { + "reference": "urn:uuid:e00e96a2-6dae-4036-8e72-42b5c21fdbf3" + } + ] + } + ] + } + }, + { + "fullUrl": "urn:uuid:623e785c-0f6d-4db9-8488-9809b8493537", + "resource": { + "resourceType": "Organization", + "id": "623e785c-0f6d-4db9-8488-9809b8493537", + "meta": { + "profile": [ + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-Apotheke|1.1" + ] + }, + "identifier": [ + { + "system": "http://fhir.de/sid/arge-ik/iknr", + "value": "833940499" + } + ], + "name": "Apotheke Crystal Claire Waters", + "address": [ + { + "type": "physical", + "line": [ + "Görresstr. 789" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "789" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Görresstr." + } + ] + } + ], + "city": "Süd Eniefeld", + "postalCode": "48480", + "country": "D" + } + ] + } + }, + { + "fullUrl": "urn:uuid:39618663-4b23-43de-ab1d-db25b2d85130", + "resource": { + "resourceType": "Invoice", + "id": "39618663-4b23-43de-ab1d-db25b2d85130", + "meta": { + "profile": [ + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-Abrechnungszeilen|1.1" + ] + }, + "status": "issued", + "type": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-CS-ERP-InvoiceTyp", + "code": "Abrechnungszeilen" + } + ] + }, + "lineItem": [ + { + "sequence": 1, + "chargeItemCodeableConcept": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "83251243" + } + ] + }, + "priceComponent": [ + { + "extension": [ + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-KostenVersicherter", + "extension": [ + { + "url": "Kategorie", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-PKV-CS-ERP-KostenVersicherterKategorie", + "code": "0" + } + ] + } + }, + { + "url": "Kostenbetrag", + "valueMoney": { + "value": 3.81, + "currency": "EUR" + } + } + ] + }, + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-MwStSatz", + "valueDecimal": 11.06 + } + ], + "type": "informational", + "factor": 1, + "amount": { + "value": 6.23, + "currency": "EUR" + } + } + ] + }, + { + "sequence": 2, + "chargeItemCodeableConcept": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "22894670" + } + ] + }, + "priceComponent": [ + { + "extension": [ + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-KostenVersicherter", + "extension": [ + { + "url": "Kategorie", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-PKV-CS-ERP-KostenVersicherterKategorie", + "code": "0" + } + ] + } + }, + { + "url": "Kostenbetrag", + "valueMoney": { + "value": 213.88, + "currency": "EUR" + } + } + ] + }, + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-MwStSatz", + "valueDecimal": 11.06 + } + ], + "type": "informational", + "factor": 1, + "amount": { + "value": 527.97, + "currency": "EUR" + } + } + ] + } + ], + "totalGross": { + "extension": [ + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-Gesamtzuzahlung", + "valueMoney": { + "value": 217.69, + "currency": "EUR" + } + } + ], + "value": 534.20, + "currency": "EUR" + } + } + }, + { + "fullUrl": "urn:uuid:e00e96a2-6dae-4036-8e72-42b5c21fdbf3", + "resource": { + "resourceType": "MedicationDispense", + "id": "e00e96a2-6dae-4036-8e72-42b5c21fdbf3", + "meta": { + "profile": [ + "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-PR-ERP-Abgabeinformationen|1.1" + ] + }, + "extension": [ + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-PKV-EX-ERP-AbrechnungsTyp", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-PKV-CS-ERP-AbrechnungsTyp", + "code": "1" + } + ] + } + }, + { + "url": "http://fhir.abda.de/eRezeptAbgabedaten/StructureDefinition/DAV-EX-ERP-Abrechnungszeilen", + "valueReference": { + "reference": "urn:uuid:39618663-4b23-43de-ab1d-db25b2d85130" + } + } + ], + "status": "completed", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/data-absent-reason", + "code": "not-applicable" + } + ] + }, + "performer": [ + { + "actor": { + "reference": "urn:uuid:623e785c-0f6d-4db9-8488-9809b8493537" + } + } + ], + "authorizingPrescription": [ + { + "identifier": { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.000.001.205.203.40" + } + } + ], + "type": { + "coding": [ + { + "system": "http://fhir.abda.de/eRezeptAbgabedaten/CodeSystem/DAV-CS-ERP-MedicationDispenseTyp", + "code": "Abgabeinformationen" + } + ] + }, + "whenHandedOver": "2023-02-17" + } + } + ], + "signature": { + "type": [ + { + "system": "urn:iso-astm:E1762-95:2013", + "code": "1.2.840.10065.1.12.1.1" + } + ], + "when": "2023-02-17T14:07:47.809+00:00", + "who": { + "reference": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Device/1" + }, + "sigFormat": "application/pkcs7-mime", + "data": "MIIuswYJKoZIhvcNAQcCoIIupDCCLqACAQExDTALBglghkgBZQMEAgEwghlq" + } + } + }, + { + "fullUrl": "urn:uuid:775157da-afc8-4248-b90b-a32163895323", + "resource": { + "resourceType": "Bundle", + "meta": { + "lastUpdated": "2023-02-17T15:07:40.162+01:00", + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle|1.1.0" + ] + }, + "identifier": { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.000.001.205.203.40" + }, + "type": "document", + "timestamp": "2023-02-17T15:07:40.162+01:00", + "entry": [ + { + "fullUrl": "https://pvs.gematik.de/fhir/Composition/25ecd923-1d58-4e74-a0b8-dde43bb06b5e", + "resource": { + "resourceType": "Composition", + "id": "25ecd923-1d58-4e74-a0b8-dde43bb06b5e", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Composition|1.1.0" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_FOR_PKV_Tariff", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_PKV_TARIFF", + "code": "03" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_FOR_Legal_basis", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_STATUSKENNZEICHEN", + "code": "00" + } + } + ], + "status": "final", + "type": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_FORMULAR_ART", + "code": "e16A" + } + ] + }, + "subject": { + "reference": "Patient/0e69e4e7-f2c5-4bd6-bf25-5af4e715c472" + }, + "date": "2023-02-17T15:07:40+01:00", + "author": [ + { + "reference": "Practitioner/d31cee47-e0e8-4bd6-82f3-e70daecd4b7b", + "type": "Practitioner" + }, + { + "type": "Device", + "identifier": { + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_FOR_Pruefnummer", + "value": "GEMATIK/410/2109/36/123" + } + } + ], + "title": "elektronische Arzneimittelverordnung", + "custodian": { + "reference": "Organization/4e118502-4ed8-45f5-9c79-9a64eaab88f6" + }, + "section": [ + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Section_Type", + "code": "Coverage" + } + ] + }, + "entry": [ + { + "reference": "Coverage/06f31815-aea8-490a-8c0b-b3123b1600cf" + } + ] + }, + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Section_Type", + "code": "Prescription" + } + ] + }, + "entry": [ + { + "reference": "MedicationRequest/28744ee3-ff3a-4793-9036-c11d6b4b105f" + } + ] + } + ] + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/MedicationRequest/28744ee3-ff3a-4793-9036-c11d6b4b105f", + "resource": { + "resourceType": "MedicationRequest", + "id": "28744ee3-ff3a-4793-9036-c11d6b4b105f", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Prescription|1.1.0" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_BVG", + "valueBoolean": false + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee", + "valueBoolean": false + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Multiple_Prescription", + "extension": [ + { + "url": "Kennzeichen", + "valueBoolean": false + } + ] + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_FOR_StatusCoPayment", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_StatusCoPayment", + "code": "0" + } + } + ], + "status": "active", + "intent": "order", + "medicationReference": { + "reference": "Medication/368dadee-d6d9-425b-afbd-93ccbf109ad8" + }, + "subject": { + "reference": "Patient/0e69e4e7-f2c5-4bd6-bf25-5af4e715c472" + }, + "authoredOn": "2023-02-17", + "requester": { + "reference": "Practitioner/d31cee47-e0e8-4bd6-82f3-e70daecd4b7b" + }, + "insurance": [ + { + "reference": "Coverage/06f31815-aea8-490a-8c0b-b3123b1600cf" + } + ], + "dosageInstruction": [ + { + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_DosageFlag", + "valueBoolean": true + } + ], + "text": "1-0-0-0" + } + ], + "dispenseRequest": { + "quantity": { + "value": 1, + "system": "http://unitsofmeasure.org", + "code": "{Package}" + } + }, + "substitution": { + "allowedBoolean": true + } + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/Medication/368dadee-d6d9-425b-afbd-93ccbf109ad8", + "resource": { + "resourceType": "Medication", + "id": "368dadee-d6d9-425b-afbd-93ccbf109ad8", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN|1.1.0" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_Base_Medication_Type", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "version": "http://snomed.info/sct/900000000000207008/version/20220331", + "code": "763158003", + "display": "Medicinal product (product)" + } + ] + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + }, + { + "url": "http://fhir.de/StructureDefinition/normgroesse", + "valueCode": "NB" + } + ], + "code": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "17091124" + } + ], + "text": "Schmerzmittel" + }, + "form": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM", + "code": "TAB" + } + ] + }, + "amount": { + "numerator": { + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_PackagingSize", + "valueString": "1" + } + ], + "unit": "Stk" + }, + "denominator": { + "value": 1 + } + } + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/Patient/0e69e4e7-f2c5-4bd6-bf25-5af4e715c472", + "resource": { + "resourceType": "Patient", + "id": "0e69e4e7-f2c5-4bd6-bf25-5af4e715c472", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.1.0" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/identifier-type-de-basis", + "code": "PKV" + } + ] + }, + "system": "http://fhir.de/sid/pkv/kvid-10", + "value": "X110465770" + } + ], + "name": [ + { + "use": "official", + "family": "Angermänn", + "_family": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Angermänn" + } + ] + }, + "given": [ + "Günther" + ] + } + ], + "birthDate": "1976-04-30", + "address": [ + { + "type": "both", + "line": [ + "Weiherstr. 74a" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "74a" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Weiherstr." + } + ] + } + ], + "city": "Büttnerdorf", + "postalCode": "67411", + "country": "D" + } + ] + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/Organization/4e118502-4ed8-45f5-9c79-9a64eaab88f6", + "resource": { + "resourceType": "Organization", + "id": "4e118502-4ed8-45f5-9c79-9a64eaab88f6", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Organization|1.1.0" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "BSNR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR", + "value": "734374849" + } + ], + "name": "Arztpraxis Schraßer", + "telecom": [ + { + "system": "phone", + "value": "(05808) 9632619" + }, + { + "system": "email", + "value": "andre.teufel@xn--schffer-7wa.name" + } + ], + "address": [ + { + "type": "both", + "line": [ + "Halligstr. 98" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "98" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Halligstr." + } + ] + } + ], + "city": "Alt Mateo", + "postalCode": "85005", + "country": "D" + } + ] + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/Coverage/06f31815-aea8-490a-8c0b-b3123b1600cf", + "resource": { + "resourceType": "Coverage", + "id": "06f31815-aea8-490a-8c0b-b3123b1600cf", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Coverage|1.1.0" + ] + }, + "extension": [ + { + "url": "http://fhir.de/StructureDefinition/gkv/besondere-personengruppe", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_PERSONENGRUPPE", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/dmp-kennzeichen", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DMP", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/wop", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_ITA_WOP", + "code": "71" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/versichertenart", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_VERSICHERTENSTATUS", + "code": "1" + } + } + ], + "status": "active", + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/versicherungsart-de-basis", + "code": "PKV" + } + ] + }, + "beneficiary": { + "reference": "Patient/53d4475b-bff0-470a-89a4-1811c832ee06" + }, + "payor": [ + { + "identifier": { + "system": "http://fhir.de/sid/arge-ik/iknr", + "value": "100843242" + }, + "display": "Künstler-Krankenkasse Baden-Württemberg" + } + ] + } + }, + { + "fullUrl": "https://pvs.gematik.de/fhir/Practitioner/d31cee47-e0e8-4bd6-82f3-e70daecd4b7b", + "resource": { + "resourceType": "Practitioner", + "id": "d31cee47-e0e8-4bd6-82f3-e70daecd4b7b", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Practitioner|1.1.0" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "LANR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_ANR", + "value": "443236256" + } + ], + "name": [ + { + "use": "official", + "family": "Schraßer", + "_family": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Schraßer" + } + ] + }, + "given": [ + "Dr." + ], + "prefix": [ + "Dr." + ], + "_prefix": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", + "valueCode": "AC" + } + ] + } + ] + } + ], + "qualification": [ + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_Qualification_Type", + "code": "00" + } + ] + } + }, + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_Berufsbezeichnung", + "code": "Berufsbezeichnung" + } + ], + "text": "Super-Facharzt für alles Mögliche" + } + } + ] + } + } + ], + "signature": { + "type": [ + { + "system": "urn:iso-astm:E1762-95:2013", + "code": "1.2.840.10065.1.12.1.1" + } + ], + "when": "2023-02-17T14:07:47.806+00:00", + "who": { + "reference": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Device/1" + }, + "sigFormat": "application/pkcs7-mime", + "data": "MII01wYJKoZIhvcNAQcCoII0yDCCNMQCAQUxDTALBglghkgBZQM" + } + } + }, + { + "fullUrl": "urn:uuid:c8d36312-0000-0000-0003-000000000000", + "resource": { + "resourceType": "Bundle", + "meta": { + "profile": [ + "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Bundle|1.2" + ] + }, + "identifier": { + "system": "https://gematik.de/fhir/erp/NamingSystem/GEM_ERP_NS_PrescriptionId", + "value": "200.000.001.205.203.40" + }, + "type": "document", + "timestamp": "2023-02-17T14:07:43.665+00:00", + "link": [ + { + "relation": "self", + "url": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Task/200.000.001.205.203.40/$close/" + } + ], + "entry": [ + { + "fullUrl": "urn:uuid:0cf976ed-8a4c-4078-bc3b-e935f06b4362", + "resource": { + "resourceType": "Composition", + "id": "0cf976ed-8a4c-4078-bc3b-e935f06b4362", + "meta": { + "profile": [ + "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Composition|1.2" + ] + }, + "extension": [ + { + "url": "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_EX_Beneficiary", + "valueIdentifier": { + "system": "https://gematik.de/fhir/sid/telematik-id", + "value": "3-SMC-B-Testkarte-883110000116873" + } + } + ], + "status": "final", + "type": { + "coding": [ + { + "system": "https://gematik.de/fhir/erp/CodeSystem/GEM_ERP_CS_DocumentType", + "code": "3", + "display": "Receipt" + } + ] + }, + "date": "2023-02-17T14:07:43.664+00:00", + "author": [ + { + "reference": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Device/1" + } + ], + "title": "Quittung", + "event": [ + { + "period": { + "start": "2023-02-17T14:07:42.401+00:00", + "end": "2023-02-17T14:07:43.664+00:00" + } + } + ], + "section": [ + { + "entry": [ + { + "reference": "Binary/PrescriptionDigest-200.000.001.205.203.40" + } + ] + } + ] + } + }, + { + "fullUrl": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Device/1", + "resource": { + "resourceType": "Device", + "id": "1", + "meta": { + "profile": [ + "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Device|1.2" + ] + }, + "status": "active", + "serialNumber": "1.9.0", + "deviceName": [ + { + "name": "E-Rezept Fachdienst", + "type": "user-friendly-name" + } + ], + "version": [ + { + "value": "1.9.0" + } + ], + "contact": [ + { + "system": "email", + "value": "betrieb@gematik.de" + } + ] + } + }, + { + "fullUrl": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Binary/PrescriptionDigest-200.000.001.205.203.40", + "resource": { + "resourceType": "Binary", + "id": "PrescriptionDigest-200.000.001.205.203.40", + "meta": { + "versionId": "1", + "profile": [ + "https://gematik.de/fhir/erp/StructureDefinition/GEM_ERP_PR_Digest|1.2" + ] + }, + "contentType": "application/octet-stream", + "data": "ZQsm4k/OW69rLio6As1LfoTGrAEnvqNUzKBKbQRJbb4=" + } + } + ], + "signature": { + "type": [ + { + "system": "urn:iso-astm:E1762-95:2013", + "code": "1.2.840.10065.1.12.1.1" + } + ], + "when": "2023-02-17T14:07:47.808+00:00", + "who": { + "reference": "https://erp-dev.zentral.erp.splitdns.ti-dienste.de/Device/1" + }, + "sigFormat": "application/pkcs7-mime", + "data": "MIIUmwYJKoZIhvcNAQcCoIIUjDCCFIgCAQUxDTALBglghkgBZQMEAgEwggp1Bgkqh" + } + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/pharmacy_binary.json b/common/src/commonTest/resources/fhir/pharmacy_binary.json new file mode 100644 index 00000000..28ee6fb5 --- /dev/null +++ b/common/src/commonTest/resources/fhir/pharmacy_binary.json @@ -0,0 +1,35 @@ +{ + "id": "12cdf428-72cc-4fd6-a5e3-e7cbc01038b6", + "meta": { + "lastUpdated": "2023-01-07T12:00:01.9253389+01:00" + }, + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + "link": [ + { + "relation": "self", + "url": "Bundle12cdf428-72cc-4fd6-a5e3-e7cbc01038b6" + } + ], + "entry": [ + { + "resource": { + "id": "TEST-TEST-TEST-TEST-TEST", + "meta": { + "lastUpdated": "2023-01-07T12:00:01.9253389+01:00", + "versionId": "1" + }, + "resourceType": "Binary", + "securityContext": { + "reference": "Location/TEST-TEST-TEST-TEST-TEST" + }, + "contentType": "application/pkix-cert", + "data": "MIIFlDCCBHygAwwKGi44czSg==" + }, + "search": { + "mode": "match" + } + } + ] +} diff --git a/gradle.properties b/gradle.properties index 56977acf..b11d87fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ buildkonfig.flavor=googleTuInternal # VERSION_CODE=1 VERSION_NAME=1.0 -USER_AGENT=eRp-App-Android/1.9.0 GMTIK/eRezeptApp +USER_AGENT=eRp-App-Android/1.10.0 GMTIK/eRezeptApp # DATA_PROTECTION_LAST_UPDATED = 2022-01-06 #