diff --git a/CHANGELOG.md b/CHANGELOG.md index b57ac6f4d..1eaf5ea9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed ### Removed +# [3.2.1] 2023-10-05 + +### Added +* 1102: Add hint when application deleted by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1107 +* 1067 Implement sentry by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1111 +* 1114: Sort applications by status by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1117 +* 975: Show more user information by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1120 +* 1123: Add oberallgaeu by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1124 +* 1075: Create card from application by @sarahsporck in https://github.com/digitalfabrik/entitlementcard/pull/1091 + +### Fixed +* 1108: Prevent auto install entitlementcard by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1109 +* 998: Improve color api switch by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1110 +* [fix]: add partial index for admin email by @sarahsporck in https://github.com/digitalfabrik/entitlementcard/pull/1116 +* 1115: Trim search text by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1118 + +### Changed +* 1100: Use single quotes by @steffenkleinle in https://github.com/digitalfabrik/entitlementcard/pull/1101 +* build(deps): bump activesupport from 6.1.7.3 to 6.1.7.6 in /frontend/ios by @dependabot in https://github.com/digitalfabrik/entitlementcard/pull/1099 +* Extract linting rules into separate file by @sarahsporck in https://github.com/digitalfabrik/entitlementcard/pull/1104 +* Upgrade backend dependencies by @michael-markl in https://github.com/digitalfabrik/entitlementcard/pull/1112 +* 1044: Only digital card default checked by @f1sh1918 in https://github.com/digitalfabrik/entitlementcard/pull/1119 +* build(deps): bump graphql from 16.7.1 to 16.8.1 in /administration by @dependabot in https://github.com/digitalfabrik/entitlementcard/pull/1125 + # [3.2.0] 2023-08-23 ### Added diff --git a/administration/package.json b/administration/package.json index 2219f2eb8..65c1a43ec 100644 --- a/administration/package.json +++ b/administration/package.json @@ -1,6 +1,6 @@ { "name": "administration", - "version": "3.2.0", + "version": "3.2.1", "private": true, "dependenciesComments": { "typescript": "Keeping on 5.0.x because of current incompatibility of 5.1.x with @typescript-eslint" diff --git a/administration/src/bp-modules/applications/ApplicationCard.tsx b/administration/src/bp-modules/applications/ApplicationCard.tsx index e39df26e5..f12940586 100644 --- a/administration/src/bp-modules/applications/ApplicationCard.tsx +++ b/administration/src/bp-modules/applications/ApplicationCard.tsx @@ -150,7 +150,7 @@ const ApplicationCard = ({ {withdrawalDate && ( - Der Antrag wurde vom Antragssteller am {formatDateWithTimezone(withdrawalDate, config.timezone)}{' '} + Der Antrag wurde vom Antragsteller am {formatDateWithTimezone(withdrawalDate, config.timezone)}{' '} zurückgezogen.
Bitte löschen Sie den Antrag zeitnah.
diff --git a/administration/src/bp-modules/cards/ImportCardsInput.tsx b/administration/src/bp-modules/cards/ImportCardsInput.tsx index 029aae993..4cfa2bc05 100644 --- a/administration/src/bp-modules/cards/ImportCardsInput.tsx +++ b/administration/src/bp-modules/cards/ImportCardsInput.tsx @@ -123,7 +123,7 @@ const ImportCardsInput = ({ setCardBlueprints, lineToBlueprint, headers }: Impor description={} action={ - + } /> diff --git a/administration/src/cards/pdf/PdfQrCodeElement.ts b/administration/src/cards/pdf/PdfQrCodeElement.ts index 55cfa1e41..6eb78405a 100644 --- a/administration/src/cards/pdf/PdfQrCodeElement.ts +++ b/administration/src/cards/pdf/PdfQrCodeElement.ts @@ -1,6 +1,8 @@ import { PDFPage } from 'pdf-lib' import { QrCode } from '../../generated/card_pb' +import { uint8ArrayToBase64 } from '../../util/base64' +import { isDevMode } from '../../util/helper' import { drawQRCode } from '../../util/qrcode' import { Coordinates, PdfElement, mmToPt } from './PdfElements' @@ -27,6 +29,11 @@ const pdfQrCodeElement: PdfElement
- Sie wurden gebeten, die Angaben eines Antrags auf Ehrenamtskarte zu bestätigen. Die Antragsstellerin oder der - Antragssteller hat Sie als Kontaktperson der Organisation {verification.organizationName} angegeben. Im + Sie wurden gebeten, die Angaben eines Antrags auf Ehrenamtskarte zu bestätigen. Die Antragstellerin oder der + Antragsteller hat Sie als Kontaktperson der Organisation {verification.organizationName} angegeben. Im Folgenden können Sie den zugehörigen Antrag einsehen. Wir bitten Sie, die enthaltenen Angaben, welche die Organisation {verification.organizationName} betreffen, zu bestätigen. Falls Sie denken, die Angaben wurden fälschlicherweise gemacht, bitten wir Sie, den Angaben zu widersprechen. diff --git a/administration/src/mui-modules/application/forms/WorkAtOrganizationForm.tsx b/administration/src/mui-modules/application/forms/WorkAtOrganizationForm.tsx index ed1129403..287ca6a35 100644 --- a/administration/src/mui-modules/application/forms/WorkAtOrganizationForm.tsx +++ b/administration/src/mui-modules/application/forms/WorkAtOrganizationForm.tsx @@ -67,11 +67,7 @@ const WorkAtOrganizationForm: Form ( <> - -

Angaben zur Tätigkeit

+

Angaben zu Ihrer ehrenamtlichen Tätigkeit

+ window.location.hostname === 'localhost' diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/Mailer.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/Mailer.kt index b76f2878d..825116025 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/Mailer.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/Mailer.kt @@ -55,7 +55,7 @@ object Mailer { .from(fromName, smtpConfig.username) .withSubject(subject) .withPlainText(message) - .withHeader("Content-Type", "text/plain; charset=utf8") + .withHeader("Content-Type", "text/plain; charset=UTF-8") .buildEmail() ).join() } catch (exception: MailException) { @@ -134,8 +134,8 @@ object Mailer { val message = """ Guten Tag ${applicationVerification.contactName}, - Sie wurden gebeten, die Angaben eines Antrags auf eine Ehrenamtskarte zu bestätigen. Die Antragsstellerin oder der - Antragssteller hat Sie als Kontaktperson der Organisation ${applicationVerification.organizationName} angegeben. + Sie wurden gebeten, die Angaben eines Antrags auf eine Ehrenamtskarte zu bestätigen. Die Antragstellerin oder der + Antragsteller hat Sie als Kontaktperson der Organisation ${applicationVerification.organizationName} angegeben. Sie können den Antrag unter folgendem Link einsehen und die Angaben bestätigen oder ihnen widersprechen: ${projectConfig.administrationBaseUrl}/antrag-verifizieren/${URLEncoder.encode(applicationVerification.accessKey, StandardCharsets.UTF_8)} diff --git a/docker-compose.yml b/docker-compose.yml index be2962e38..53ca33d2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: - MARIADB_AUTO_UPGRADE=1 - MARIADB_DISABLE_UPGRADE_BACKUP=1 env_file: - - ./matomo.env + - ./docker/matomo.env networks: - network matomo: @@ -57,7 +57,7 @@ services: volumes: - matomo:/var/www/html:z env_file: - - ./matomo.env + - ./docker/matomo.env ports: - 5003:80 networks: diff --git a/matomo.env b/docker/matomo.env similarity index 100% rename from matomo.env rename to docker/matomo.env diff --git a/docs/artefacts/beantragung_zusammenfassung.md b/docs/artefacts/beantragung_zusammenfassung.md index fe98d3ce2..61e4f7ac1 100644 --- a/docs/artefacts/beantragung_zusammenfassung.md +++ b/docs/artefacts/beantragung_zusammenfassung.md @@ -54,7 +54,7 @@ Auswahl aus #### Bestätigung der Organisation - Ort, Datum, Unterschrift/Stempel der Organistation -- AntragsstellerIn (Organisation/AntragsstellerIn) +- AntragstellerIn (Organisation/AntragstellerIn) - Organisation wird gefördert durch (Freitext, optional) ### 2. Juleica Inhaber (Jugendleiterkarte) diff --git a/docs/artefacts/beantragungsprozess_entwurf.drawio.svg b/docs/artefacts/beantragungsprozess_entwurf.drawio.svg index 34e57f397..aa7a96f36 100644 --- a/docs/artefacts/beantragungsprozess_entwurf.drawio.svg +++ b/docs/artefacts/beantragungsprozess_entwurf.drawio.svg @@ -1,3 +1,3 @@ -
Prozess Beantragung analoge Ehrenamtskarte
Prozess Beantragung analoge Ehrenamtskarte
Antragssteller (Engagierte/Organisationen)
Antragssteller (Engagierte/Organisationen)
Ja
Ja
Antrag
ausgefüllt
Antrag...
Absage erhalten
Absage erhalten
Antrag beendet
Ehrenamtskarte erhalten
Ehrenamtskarte e...
Sind Daten auf Karte korrekt?
Sind D...
Antrag beendet
Ehrenamtskarte erhalten
Ehrenamtskarte e...
Hier würde wir 
einen Code mitschicken
Hier...
Hier würden wir
einen Code mitschicken
Hier...
Nein
Nein
Kreisfreie Stadt/Landkreis
Kreisfreie Stadt/Landkreis
Antrag empfangen
Antrag empfangen
Nein
Nein
Antrag genehmigt?
Antrag...
Absage formulieren
Absage formulier...
Daten korrigieren
Daten korrigiere...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Ehrenamtskarte per Post verschicken
Ehrenamtskarte p...
Karten in Brief
verpacken
Karten in Brief...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Drucken auslagern?
Drucke...
Ja
Ja
Druckerei (Sozialministerium)
Druckerei (Sozialministerium)
Ehrenamtskarte per Post zurückschicken
Ehrenamtskarte p...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Antrag per Post
Antrag per Post
Ja
Ja
Viewer does not support full SVG 1.1
\ No newline at end of file +
Prozess Beantragung analoge Ehrenamtskarte
Prozess Beantragung analoge Ehrenamtskarte
Antragsteller (Engagierte/Organisationen)
Antragssteller (Engagierte/Organisationen)
Ja
Ja
Antrag
ausgefüllt
Antrag...
Absage erhalten
Absage erhalten
Antrag beendet
Ehrenamtskarte erhalten
Ehrenamtskarte e...
Sind Daten auf Karte korrekt?
Sind D...
Antrag beendet
Ehrenamtskarte erhalten
Ehrenamtskarte e...
Hier würde wir 
einen Code mitschicken
Hier...
Hier würden wir
einen Code mitschicken
Hier...
Nein
Nein
Kreisfreie Stadt/Landkreis
Kreisfreie Stadt/Landkreis
Antrag empfangen
Antrag empfangen
Nein
Nein
Antrag genehmigt?
Antrag...
Absage formulieren
Absage formulier...
Daten korrigieren
Daten korrigiere...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Ehrenamtskarte per Post verschicken
Ehrenamtskarte p...
Karten in Brief
verpacken
Karten in Brief...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Drucken auslagern?
Drucke...
Ja
Ja
Druckerei (Sozialministerium)
Druckerei (Sozialministerium)
Ehrenamtskarte per Post zurückschicken
Ehrenamtskarte p...
Ehrenamtskarte drucken
Ehrenamtskarte d...
Antrag per Post
Antrag per Post
Ja
Ja
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index ffa5cd2e9..a75aaaf91 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -36,3 +36,4 @@ lib/graphql/graphql_api.graphql.dart lib/graphql/graphql_api.graphql.g.dart lib/proto +/lib/l10n/translations.g.dart diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle index 304dcbc75..cb96822dc 100644 --- a/frontend/android/app/build.gradle +++ b/frontend/android/app/build.gradle @@ -49,13 +49,13 @@ ext.setupApplication = { ApplicationProductFlavor flavor, buildConfigName -> ext.setupVariant = { variant, buildConfigName -> var buildConfig = createBuildConfig(buildConfigName) - if (buildConfig.featureFlags.excludeX86) { + if (buildConfig.buildFeatures.excludeX86) { // Verify this using: // unzip -l frontend/build/app/outputs/apk/Bayern/debug/app-Bayern-debug.apk | grep x86 // unzip -l frontend/build/app/outputs/apk/Nuernberg/debug/app-Nuernberg-debug.apk | grep x86 variant.packaging.jniLibs.excludes.add('**/lib/x86**') } - if (buildConfig.excludeLocationPlayServices) { + if (buildConfig.buildFeatures.excludeLocationPlayServices) { // Verify this by opening the APKs in Android Studio/IntelliJ. Then select all the .dex files. // Then see if there are files located at com.android.gms (Cursive files are not present, only referenced) variant.compileConfiguration.exclude group: 'com.google.android.gms', module: 'play-services-location' diff --git a/frontend/assets/l10n/app_de.json b/frontend/assets/l10n/app_de.json new file mode 100644 index 000000000..07f4dc13d --- /dev/null +++ b/frontend/assets/l10n/app_de.json @@ -0,0 +1,177 @@ +{ + "about": { + "dependencies": "Software-Bibliotheken", + "developmentOptions": "Entwickleroptionen", + "disclaimer": "Haftung, Haftungsausschluss und Impressum", + "infoTitle": "Info", + "languageChange": "Sprache wechseln", + "languageChangeSuccessful": "Ihre Sprache wurde erfolgreich geändert!", + "licenses": { + "one": "Lizenz", + "other": "Lizenzen" + }, + "moreInformation": "Mehr Informationen", + "numberLicenses": { + "one": "1 Lizenz", + "other": "$n Lizenzen", + "zero": "Keine Lizenzen" + }, + "privacyDeclaration": "Datenschutzerklärung", + "publisher": "Herausgeber", + "settingsTitle": "Einstellungen", + "sourceCode": "Quellcode der App", + "title": "Über" + }, + "category": { + "clothing": "Kleidung", + "clothingLong": "Kleidung/Gebrauchtes", + "culture": "Kultur", + "cultureLong": "Bildung/Kultur/Unterhaltung", + "cultureLongNuernberg": "Kultur/Museen/Freizeit", + "digitalParticipation": "Digitale Teilhabe", + "education": "Bildung", + "fashion": "Mode", + "fashionLong": "Mode/Beauty", + "food": "Gastronomie", + "foodLong": "Essen/Trinken/Gastronomie", + "health": "Gesundheit", + "healthLong": "Gesundheit/Sport/Wellness", + "leisure": "Freizeit", + "leisureLong": "Freizeit/Reise/Unterkünfte", + "living": "Einrichtung", + "livingLong": "Wohnen/Haus/Garten", + "lunchTables": "Mittagstische", + "media": "Multimedia", + "mobility": "Mobilität", + "mobilityLong": "Auto/Zweirad", + "movies": "Schauspiel", + "moviesLong": "Kinos/Theater/Konzerte", + "other": "Anderes", + "pharmacies": "Apotheken", + "pharmaciesLong": "Apotheken/Gesundheit", + "services": "Dienstleistung", + "servicesLong": "Dienstleistungen/Finanzen", + "sports": "Sport", + "sportsLong": "Sport/Bewegung/Tanz" + }, + "common": { + "cancel": "Abbrechen", + "checkConnection": "Bitte Internetverbindung prüfen.", + "connectionFailed": "Keine Verbindung möglich", + "done": "Fertig", + "moreActions": "Weitere Aktionen", + "next": "Weiter", + "ok": "OK", + "openSettings": "Einstellungen öffnen", + "previous": "Zurück", + "settings": "Einstellungen", + "tryAgain": "Erneut versuchen" + }, + "identification": { + "activate": "Aktivieren", + "activateCurrentDeviceDescription": "Ihre Karte ist bereits auf einem anderen Gerät aktiviert. Wenn Sie Ihre Karte auf diesem Gerät aktivieren, wird sie auf Ihrem anderen Gerät automatisch deaktiviert.", + "activateCurrentDeviceTitle": "Karte auf diesem Gerät aktivieren?", + "activateDescription": "Sie haben die Ehrenamtskarte bereits beantragt und den Aktivierungscode Ihrer digitalen Ehrenamtskarte erhalten? Scannen Sie den Code hier ein.", + "activateTitle": "Karte aktivieren", + "applyDescription": "Sie sind ehrenamtlich engagiert, haben aber noch keine Ehrenamtskarte? Hier können Sie Ihre Ehrenamtskarte beantragen.", + "applyTitle": "Beantragen", + "authenticationPossible": "Mit diesem QR-Code können Sie sich bei Akzeptanzstellen ausweisen:", + "cameraAccessRequired": "Zugriff auf Kamera erforderlich", + "cameraAccessRequiredSettings": "Um einen QR-Code einzuscannen, benötigt die App Zugriff auf die Kamera. In den Einstellungen können Sie der App den Zugriff auf die Kamera erlauben.", + "cardAlreadyActivated": "Der eingescannte QRCode wurde bereits aktiviert.", + "cardExpired": "Ihre Karte ist abgelaufen. Unter \"Weitere Aktionen\" können Sie einen Antrag auf Verlängerung stellen.", + "cardInvalid": "Ihre Karte ist ungültig. Sie wurde entweder widerrufen oder auf einem anderen Gerät aktiviert.", + "cardNotYetValid": "Der Gültigkeitszeitraum Ihrer Karte hat noch nicht begonnen.", + "checkFailed": "Ihre Karte konnte nicht auf ihre Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.", + "checkRequired": "Prüfung nötig", + "checkingCode": "Der QR-Code wird durch eine Server-Anfrage geprüft.", + "codeExpired": "Der eingescannte Code ist bereits am {{expirationDate}} abgelaufen.", + "codeInvalid": "Der Inhalt des eingescannten Codes kann nicht verstanden werden. Vermutlich handelt es sich um einen QR-Code, der nicht für diese App generiert wurde.", + "codeInvalidMissing": "Der Inhalt des eingescannten Codes ist unvollständig. (Fehlercode: {{missing}}Missing)", + "codeSavingFailed": "Der eingescannte Code kann nicht in der App gespeichert werden.", + "codeUnknownError": "Beim Scannen des QR-Codes ist ein unbekannter Fehler aufgetreten.", + "codeVerificationFailed": "Der eingescannte Code konnte vom Server nicht verifiziert werden.", + "codeActivationFailedConnection": "Der eingescannte Code konnte nicht aktiviert werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.", + "codeVerificationFailedConnection": "Der eingescannte Code konnte nicht verifiziert werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.", + "compareWithID": "Gleichen Sie die angezeigten Daten mit einem amtlichen Lichtbildausweis ab.", + "comparedWithID": "Ich habe die Daten mit einem amtlichen Lichtbildausweis abgeglichen.", + "flashOff": "Blitz aus", + "flashOn": "Blitz an", + "internetRequired": "Eine Internetverbindung wird benötigt.", + "moreActionsActivateDescription": "Ihre hinterlegte Ehrenamtskarte bleibt erhalten. Sie können diese manuell entfernen.", + "moreActionsActivateLimitDescription": "Um eine weitere Ehrenamtskarte hinzuzufügen, müssen Sie zuerst eine vorhandene Ehrenamtskarte löschen.", + "moreActionsActivateTitle": "Weitere Ehrenamtskarte hinzufügen", + "moreActionsApplyDescription": "Ihre hinterlegte Karte bleibt erhalten.", + "moreActionsApplyTitle": "Ehrenamtskarte beantragen oder verlängern", + "moreActionsRemoveDescription": "Nach der Auswahl wird diese Ehrenamtskarte vom Gerät gelöscht.", + "moreActionsRemoveTitle": "Diese Ehrenamtskarte löschen", + "moreActionsVerifyDescription": "Prüfen Sie die Gültigkeit einer digitalen Ehrenamtskarte.", + "moreActionsVerifyTitle": "Eine digitale Ehrenamtskarte prüfen", + "notVerified": "Nicht verifiziert", + "removeDescription": "Wenn diese Karte gelöscht wird, muss diese für eine erneute Verwendung neu hinzugefügt werden.", + "removeTitle": "Diese Karte löschen?", + "scanCode": "Scannen Sie den QR-Code, der auf dem \"Ausweisen\"-Tab Ihres Gegenübers angezeigt wird.", + "scanQRCode": "Halten Sie die Kamera auf den QR Code.", + "scanningFailed": "Fehler beim Lesen des Codes", + "selfieCamera": "Frontkamera", + "standardCamera": "Standard-Kamera", + "stopShowing": "Nicht mehr anzeigen", + "timeIncorrect": "Die Uhrzeit Ihres Geräts scheint nicht zu stimmen. Bitte synchronisieren Sie die Uhrzeit in den Systemeinstellungen.", + "title": "Ausweisen", + "unlimited": "unbegrenzt", + "validFromUntil": "Gültig: {{startDate}} bis {{expirationDate}}", + "validUntil": "Gültig bis: {{expirationDate}}", + "verificationSuccessful": "Karte ist gültig", + "verifyDescription": "Sie möchten die Gültigkeit einer digitalen Ehrenamtskarte prüfen? Scannen Sie den Code hier ein.", + "verifyInfoTitle": "So prüfen Sie die Gültigkeit einer Ehrenamtskarte", + "verifyTitle": "Gültigkeit prüfen" + }, + "intro": { + "applyDescription": "Im Formular geben Sie Informationen über sich und Ihre ehrenamtliche Tätigkeit an. Anschließend wird der Antrag weitergeleitet und von der zuständigen Stelle bearbeitet.", + "applyTitle": "Wie kann ich die Ehrenamtskarte beantragen?", + "locationDescription": "Wir können Ihren Standort auf der Karte anzeigen und Akzeptanzstellen in Ihrer Umgebung anzeigen. Wenn Sie diese Hilfen nutzen möchten, benötigen wir Ihre Zustimmung. Ihr Standort wird nicht gespeichert.", + "locationTitle": "Finden Sie Akzeptanzstellen in Ihrer Umgebung!", + "usageDescription": "Auf der Karte von Bayern können Sie alle Akzeptanzstellen finden. Tippen Sie auf einen Standort, um mehr Informationen sehen zu können.", + "usageTitle": "Wo kann ich meine Ehrenamtskarte nutzen?", + "welcomeDescription": "Vielen Dank, dass Sie sich die App zur Bayerischen Ehrenamtskarte heruntergeladen haben!", + "welcomeTitle": "Willkommen!" + }, + "location": { + "activateLocationAccess": "Standortermittlung aktivieren", + "activateLocationAccessRationale": "Erlauben Sie der App Ihren Standort zu benutzen, um Akzeptanzstellen in Ihrer Umgebung anzuzeigen.", + "activateLocationAccessSettings": "Aktivieren Sie die Standortermittlung in den Einstellungen.", + "askPermissionsAgain": "Soll nocheinmal nach der Berechtigung gefragt werden?", + "grantLocation": "Ich möchte meinen Standort freigeben.", + "grantPermission": "Berechtigung erteilen", + "locationAccessDeactivated": "Die Standortfreigabe ist deaktiviert.", + "locationGranted": "Standort ist freigegeben.", + "locationPermission": "Standortberechtigung", + "checkSettings": "Prüfe Einstellungen..." + }, + "map": { + "mapData": "Kartendaten", + "osmContributors": "OpenStreetMap Mitwirkende", + "showMapCopyright": "Zeige Infos über das Urheberrecht der Kartendaten", + "title": "Karte" + }, + "search": { + "filterByCategories": "Nach Kategorien filtern", + "findCloseBy": "In meiner Nähe suchen", + "noAcceptingStoresFound": "Auf diese Suche trifft keine Akzeptanzstelle zu.", + "searchHint": "Tippen, um zu suchen …", + "searchResults": "Suchresultate", + "title": "Suche" + }, + "store": { + "acceptingStore": "Akzeptanzstelle", + "acceptingStoreNotFound": "Akzeptanzstelle nicht gefunden.", + "address": "Adresse", + "email": "E-Mail", + "loadingDataFailed": "Fehler beim Laden der Daten.", + "noDescriptionAvailable": "Keine Beschreibung verfügbar", + "phone": "Telefon", + "showOnMap": "Auf Karte zeigen", + "unknownCategory": "Unbekannte Kategorie", + "website": "Website" + } +} diff --git a/frontend/assets/l10n/app_en.json b/frontend/assets/l10n/app_en.json new file mode 100644 index 000000000..c2d7aaa4d --- /dev/null +++ b/frontend/assets/l10n/app_en.json @@ -0,0 +1,177 @@ +{ + "about": { + "dependencies": "Libraries", + "developmentOptions": "Developer options", + "disclaimer": "Liability, disclaimer and imprint", + "infoTitle": "Info", + "languageChange": "Change language", + "languageChangeSuccessful": "Your language was changed successfully!", + "licenses": { + "one": "License", + "other": "Licenses" + }, + "moreInformation": "More information", + "numberLicenses": { + "one": "1 license", + "other": "$n licenses", + "zero": "No licenses" + }, + "privacyDeclaration": "Privacy policy", + "publisher": "Publisher", + "settingsTitle": "Settings", + "sourceCode": "Source code", + "title": "About" + }, + "category": { + "clothing": "Clothing", + "clothingLong": "Clothing/Second hand", + "culture": "Culture", + "cultureLong": "Education/Culture/Entertainment", + "cultureLongNuernberg": "Culture/Museums/Leisure", + "digitalParticipation": "Digital participation", + "education": "Education", + "fashion": "Fashion", + "fashionLong": "Fashion/Beauty", + "food": "Gastronomy", + "foodLong": "Food/Drink/Gastronomy", + "health": "Health", + "healthLong": "Health/Sports/Wellness", + "leisure": "Leisure", + "leisureLong": "Leisure/Travel/Accommodation", + "living": "Furnishing", + "livingLong": "Living/Home/Garden", + "lunchTables": "Lunch offers", + "media": "Multimedia", + "mobility": "Mobility", + "mobilityLong": "Car/Bicycle", + "movies": "Shows", + "moviesLong": "Cinema/Theater/Concerts", + "other": "Other", + "pharmacies": "Pharmacies", + "pharmaciesLong": "Pharmacies/Health", + "services": "Services", + "servicesLong": "Services/Finances", + "sports": "Sports", + "sportsLong": "Sports/Movement/Dance" + }, + "common": { + "cancel": "Cancel", + "checkConnection": "Please check internet connection.", + "connectionFailed": "No connection possible", + "done": "Done", + "moreActions": "More actions", + "next": "Next", + "ok": "OK", + "openSettings": "Open settings", + "previous": "Back", + "settings": "Settings", + "tryAgain": "Try again" + }, + "identification": { + "activate": "Activate", + "activateCurrentDeviceDescription": "Your card is already activated on another device. If you activate your card on this device, it will be automatically deactivated on your other device.", + "activateCurrentDeviceTitle": "Activate card on this device?", + "activateDescription": "You have already applied for the Ehrenamtskarte and received the activation code for your digital Ehrenamtskarte? Scan the code here.", + "activateTitle": "Activate card", + "applyDescription": "Are you involved in voluntary work but do not yet have an Ehrenamtskarte? You can apply for your Ehrenamtskarte here.", + "applyTitle": "Apply", + "authenticationPossible": "You can use this QR code to identify yourself at acceptance points:", + "cameraAccessRequired": "Access to camera required", + "cameraAccessRequiredSettings": "To scan a QR code, the app needs access to the camera. You can allow the app to access the camera in the settings.", + "cardAlreadyActivated": "The scanned QR code has already been activated.", + "cardExpired": "Your card has expired. You can apply for renewal under \"More actions\"", + "cardInvalid": "Your card is invalid. It has either been revoked or activated on another device.", + "cardNotYetValid": "The validity period of your card has not yet started.", + "checkFailed": "Your card could not be verified. Please make sure you have an internet connection and try again.", + "checkRequired": "Verification necessary", + "checkingCode": "The QR code is verified by a server request.", + "codeExpired": "The scanned code has already expired on {{expirationDate}}.", + "codeInvalid": "The content of the scanned code cannot be understood. It is probably a QR code that was not generated for this app.", + "codeInvalidMissing": "The content of the scanned code is incomplete. (Error code: {{missing}}Missing)", + "codeSavingFailed": "The scanned code cannot be saved in the app.", + "codeUnknownError": "An unknown error occurred while scanning the QR code.", + "codeVerificationFailed": "The scanned code could not be verified by the server.", + "codeActivationFailedConnection": "The scanned code could not be activated. Please make sure you have an internet connection and try again.", + "codeVerificationFailedConnection": "The scanned code could not be verified. Please make sure you have an internet connection and try again.", + "compareWithID": "Verify the displayed data against an official photo ID.", + "comparedWithID": "I verified the data against an official photo ID.", + "flashOff": "Flash off", + "flashOn": "Flash on", + "internetRequired": "An internet connection is required.", + "moreActionsActivateDescription": "Your saved cards will be retained. You can remove them manually.", + "moreActionsActivateLimitDescription": "To add another Ehrenamtskarte, you must first delete an existing Ehrenamtskarte.", + "moreActionsActivateTitle": "Add another Ehrenamtskarte", + "moreActionsApplyDescription": "Your saved cards will be retained.", + "moreActionsApplyTitle": "Apply for or extend an Ehrenamtskarte", + "moreActionsRemoveDescription": "After selection, this Ehrenamtskarte will be deleted from the device.", + "moreActionsRemoveTitle": "Delete this Ehrenamtskarte?", + "moreActionsVerifyDescription": "Check the validity of an Ehrenamtskarte", + "moreActionsVerifyTitle": "Check an Ehrenamtskarte", + "notVerified": "Not verified", + "removeDescription": "If this card is deleted, it must be added again before using it again.", + "removeTitle": "Delete this card?", + "scanCode": "Scan the QR code that appears on the \"Identify\" tab of the other party.", + "scanQRCode": "Point the camera at the QR code.", + "scanningFailed": "Error reading the code", + "selfieCamera": "Front camera", + "standardCamera": "Standard camera", + "stopShowing": "Stop showing", + "timeIncorrect": "The time of your device does not seem to be correct. Please synchronize the time in the system settings.", + "title": "Identify", + "unlimited": "unlimited", + "validFromUntil": "Valid: {{startDate}} until {{expirationDate}}", + "validUntil": "Valid until: {{expirationDate}}", + "verificationSuccessful": "Card is valid", + "verifyDescription": "You would like to check the validity of a digital Ehrenamtskarte? Scan the code here.", + "verifyInfoTitle": "How to check the validity of an Ehrenamtskarte", + "verifyTitle": "Check validity" + }, + "intro": { + "applyDescription": "Provide information about yourself and your volunteer activity in the form. The application is then forwarded and processed by the responsible office.", + "applyTitle": "How can I apply for the Ehrenamtskarte?", + "locationDescription": "We can show your location on the map and display acceptance points in your area. If you want to use these aids, we need your consent. Your location is not stored.", + "locationTitle": "Find acceptance points in your area!", + "usageDescription": "On the map of Bavaria you can find all acceptance points. Tap on a location to be able to see more information.", + "usageTitle": "Where can I use my Ehrenamtskarte?", + "welcomeDescription": "Thank you for downloading the app for the Bayerische Ehrenamtskarte!", + "welcomeTitle": "Welcome!" + }, + "location": { + "activateLocationAccess": "Activate location access", + "activateLocationAccessRationale": "Allow the app to use your location to display acceptance points in your area.", + "activateLocationAccessSettings": "Enable location access in Settings.", + "askPermissionsAgain": "Should permission be asked again?", + "grantLocation": "I would like to share my location.", + "grantPermission": "Grant permission", + "locationAccessDeactivated": "Location is disabled.", + "locationGranted": "Location is enabled.", + "locationPermission": "Location permission", + "checkSettings": "Checking settings..." + }, + "map": { + "mapData": "Map data", + "osmContributors": "OpenStreetMap Contributors", + "showMapCopyright": "Show info about map data copyright", + "title": "Map" + }, + "search": { + "filterByCategories": "Filter by categories", + "findCloseBy": "Search near me", + "noAcceptingStoresFound": "No acceptance points found matching this search.", + "searchHint": "Tap to search …", + "searchResults": "Search results", + "title": "Search" + }, + "store": { + "acceptingStore": "Acceptance points", + "acceptingStoreNotFound": "Acceptance point not found.", + "address": "Address", + "email": "E-Mail", + "loadingDataFailed": "Error loading the data.", + "noDescriptionAvailable": "No description available", + "phone": "Phone", + "showOnMap": "Show on map", + "unknownCategory": "Unknown category", + "website": "Website" + } +} diff --git a/frontend/assets/nuernberg/l10n/override_de.json b/frontend/assets/nuernberg/l10n/override_de.json new file mode 100644 index 000000000..a63359282 --- /dev/null +++ b/frontend/assets/nuernberg/l10n/override_de.json @@ -0,0 +1,37 @@ +{ + "identification": { + "activateCurrentDeviceDescription": "Ihr Pass ist bereits auf einem anderen Gerät aktiviert. Wenn Sie Ihren Pass auf diesem Gerät aktivieren, wird er auf Ihrem anderen Gerät automatisch deaktiviert.", + "activateCurrentDeviceTitle": "Pass auf diesem Gerät aktivieren?", + "activateDescription": "Sie haben den Nürnberg-Pass bereits beantragt und einen Aktivierungscode erhalten? Scannen Sie den Code hier ein.", + "activateTitle": "Pass aktivieren", + "applyDescription": "Sie haben noch keinen Nürnberg-Pass? Hier können Sie Ihren Nürnberg-Pass beantragen.", + "cardExpired": "Ihr Pass ist abgelaufen. Unter \"Weitere Aktionen\" können Sie einen Antrag auf Verlängerung stellen.", + "cardInvalid": "Ihr Pass ist ungültig. Sie wurde entweder widerrufen oder auf einem anderen Gerät aktiviert.", + "cardNotYetValid": "Der Gültigkeitszeitraum Ihres Passes hat noch nicht begonnen.", + "checkFailed": "Ihr Pass konnte nicht auf ihre Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.", + "moreActionsActivateDescription": "Ihr hinterlegter Nürnberg-Pass bleibt erhalten. Sie können diesen manuell entfernen.", + "moreActionsActivateLimitDescription": "Um einen weiteren Nürnberg-Pass hinzuzufügen, müssen Sie zuerst einen vorhandenen Nürnberg-Pass löschen.", + "moreActionsActivateTitle": "Weiteren Nürnberg-Pass hinzufügen", + "moreActionsApplyDescription": "Ihr hinterlegter Pass bleibt erhalten.", + "moreActionsApplyTitle": "Nürnberg-Pass beantragen oder verlängern", + "moreActionsRemoveDescription": "Nach der Auswahl wird dieser Nürnberg-Pass vom Gerät gelöscht.", + "moreActionsRemoveTitle": "Diesen Nürnberg-Pass löschen", + "moreActionsVerifyDescription": "Prüfen Sie die Gültigkeit eines Nürnberg-Passes.", + "moreActionsVerifyTitle": "Einen Nürnberg-Pass prüfen", + "removeDescription": "Wenn dieser Pass gelöscht wird, muss dieser für eine erneute Verwendung neu hinzugefügt werden.", + "removeTitle": "Diesen Pass löschen?", + "verificationSuccessful": "Pass ist gültig", + "verifyDescription": "Sie möchten die Gültigkeit eines Nürnberg-Passes prüfen? Scannen Sie den Code hier ein.", + "verifyInfoTitle": "So prüfen Sie die Gültigkeit eines Nürnberg-Passes" + }, + "intro": { + "applyDescription": "Im Formular geben Sie Ihre persönlichen Informationen an. Anschließend wird der Antrag weitergeleitet und von der zuständigen Stelle bearbeitet.", + "applyTitle": "Wie kann ich den Nürnberg-Pass beantragen?", + "usageDescription": "Auf der Karte von Nürnberg können Sie alle Akzeptanzstellen finden. Tippen Sie auf einen Standort, um mehr Informationen sehen zu können.", + "usageTitle": "Wo kann ich den Nürnberg-Pass nutzen?", + "welcomeDescription": "Vielen Dank, dass Sie sich die App zum Nürnberg-Pass heruntergeladen haben!" + }, + "about": { + "title": "Mehr" + } +} diff --git a/frontend/assets/nuernberg/l10n/override_en.json b/frontend/assets/nuernberg/l10n/override_en.json new file mode 100644 index 000000000..ca52269e8 --- /dev/null +++ b/frontend/assets/nuernberg/l10n/override_en.json @@ -0,0 +1,37 @@ +{ + "identification": { + "activateCurrentDeviceDescription": "Your pass is already activated on another device. If you activate your pass on this device, it will be automatically deactivated on your other device.", + "activateCurrentDeviceTitle": "Activate pass on this device?", + "activateDescription": "You have already applied for the Nürnberg-Pass and received an activation code? Scan the code here.", + "activateTitle": "Activate pass", + "applyDescription": "You do not yet have a Nürnberg-Pass? Here you can apply for your Nürnberg-Pass.", + "cardExpired": "Your pass has expired. You can apply for renewal under \"More actions\"", + "cardInvalid": "Your pass is invalid. It has either been revoked or activated on another device.", + "cardNotYetValid": "The validity period of your pass has not yet started.", + "checkFailed": "Your pass could not be verified. Please make sure you have an internet connection and try again.", + "moreActionsActivateDescription": "Your saved passes will be retained. You can remove them manually.", + "moreActionsActivateLimitDescription": "To add another Nürnberg-Pass, you must first delete an existing Nürnberg-Pass.", + "moreActionsActivateTitle": "Add another Nürnberg-Pass", + "moreActionsApplyDescription": "Your saved passes will be retained.", + "moreActionsApplyTitle": "Apply for or extend a Nürnberg-Pass", + "moreActionsRemoveDescription": "After selection, this Nürnberg-Pass will be deleted from the device.", + "moreActionsRemoveTitle": "Delete this Nürnberg-Pass?", + "moreActionsVerifyDescription": "Check the validity of a Nürnberg-Pass", + "moreActionsVerifyTitle": "Check a Nürnberg-Pass", + "removeDescription": "If this pass is deleted, it must be added again before using it again.", + "removeTitle": "Delete this pass?", + "verificationSuccessful": "Pass is valid", + "verifyDescription": "You would like to check the validity of a Nürnberg-Pass? Scan the code here.", + "verifyInfoTitle": "How to check the validity of a Nürnberg-Pass" + }, + "intro": { + "applyDescription": "Provide your personal information in the form. The application is then forwarded and processed by the responsible office.", + "applyTitle": "How can I apply for the Nürnberg-Pass?", + "usageDescription": "On the map of Nürnberg you can find all acceptance points. Tap on a location to be able to see more information.", + "usageTitle": "Where can I use my Nürnberg-Pass?", + "welcomeDescription": "Thank you for downloading the app for the Nürnberg-Pass!" + }, + "about": { + "title": "More" + } +} diff --git a/frontend/build-configs/bayern-floss/index.ts b/frontend/build-configs/bayern-floss/index.ts index bb03cc374..0b9bc95c1 100644 --- a/frontend/build-configs/bayern-floss/index.ts +++ b/frontend/build-configs/bayern-floss/index.ts @@ -12,8 +12,8 @@ let bayernFloss: BuildConfigType = { ...bayern.android, ...bayernFlossCommon, applicationId: "app.ehrenamtskarte.bayern.floss", - featureFlags: { - ...bayern.android.featureFlags, + buildFeatures: { + ...bayern.android.buildFeatures, excludeLocationPlayServices: true, excludeX86: true } diff --git a/frontend/build-configs/bayern/index.ts b/frontend/build-configs/bayern/index.ts index 220e7954b..79f0f6e30 100644 --- a/frontend/build-configs/bayern/index.ts +++ b/frontend/build-configs/bayern/index.ts @@ -1,7 +1,6 @@ import BuildConfigType, {CommonBuildConfigType} from "../types" import publisherText from "./publisherText" import disclaimerText from "./disclaimerText" -import localization from "./localization" export const bayernCommon: CommonBuildConfigType = { appName: "Ehrenamt", @@ -31,6 +30,8 @@ export const bayernCommon: CommonBuildConfigType = { showcase: "https://api.entitlementcard.app", local: "http://localhost:8000", }, + appLocales: ['de'], + localeOverridePath: null, cardBranding: { headerTextColor: "#008dc9", headerColor: "#F5F5FFF5", @@ -54,36 +55,14 @@ export const bayernCommon: CommonBuildConfigType = { boxDecorationRadius: 1, }, iconInAboutTab: "assets/bayern/icon.png", - introSlide1: { - title: "Willkommen!", - description: "Vielen Dank, dass Sie sich die App zur " + - "Bayerischen Ehrenamtskarte heruntergeladen haben!", - imagePath: "assets/bayern/icon.png" - }, - introSlide2: { - title: "Wie kann ich die Ehrenamtskarte beantragen?", - description: "Im Formular geben Sie Informationen über sich und Ihre " + - "ehrenamtliche Tätigkeit an.\nAnschließend wird " + - "der Antrag weitergeleitet und von der zuständigen Stelle bearbeitet.", - imagePath: "assets/bayern/intro_slides/apply_for_eak.png" - }, - introSlide3: { - title: "Wo kann ich meine Ehrenamtskarte nutzen?", - description: "Auf der Karte von Bayern können Sie alle Akzeptanzstellen" + - " finden.\nTippen Sie auf einen Standort, um mehr Informationen " + - "sehen zu können.", - imagePath: "assets/bayern/intro_slides/map_zoom.jpeg" - }, - introSlide4: { - title: "Finden Sie Akzeptanzstellen in Ihrer Umgebung!", - description: "Wir können Ihren Standort auf der Karte anzeigen" + - " und Akzeptanzstellen in Ihrer Umgebung anzeigen.\n" + - "Wenn Sie diese Hilfen nutzen möchten, benötigen wir Ihre " + - "Zustimmung.\nIhr Standort wird nicht gespeichert.", - imagePath: "assets/bayern/intro_slides/search_with_location.png" - }, + introSlidesImages: [ + "assets/bayern/icon.png", + "assets/bayern/intro_slides/apply_for_eak.png", + "assets/bayern/intro_slides/map_zoom.jpeg", + "assets/bayern/intro_slides/search_with_location.png", + ], featureFlags: { - verification: true + verification: true, }, applicationUrl: "https://bayern.ehrenamtskarte.app/beantragen", dataPrivacyPolicyUrl: "https://bayern.ehrenamtskarte.app/data-privacy-policy", @@ -91,7 +70,7 @@ export const bayernCommon: CommonBuildConfigType = { "Bayerisches Staatsministerium\nfür Familie, Arbeit und Soziales\nWinzererstraße 9\n80797 München", publisherText, disclaimerText, - localization, + maxCardAmount: 1 } let bayern: BuildConfigType = { @@ -99,8 +78,7 @@ let bayern: BuildConfigType = { android: { ...bayernCommon, applicationId: "de.nrw.it.giz.ehrensache.bayern.android", - featureFlags: { - ...bayernCommon.featureFlags, + buildFeatures: { excludeLocationPlayServices: false, excludeX86: false, }, diff --git a/frontend/build-configs/bayern/localization.ts b/frontend/build-configs/bayern/localization.ts deleted file mode 100644 index 5d0d91f22..000000000 --- a/frontend/build-configs/bayern/localization.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LocalizationType } from "../types" - -const localization: LocalizationType = { - identification: { - noCardView: { - applyTitle: "Beantragen", - applyDescription: - "Sie sind ehrenamtlich engagiert, haben aber noch keine Ehrenamtskarte? Hier können Sie Ihre Ehrenamtskarte beantragen.", - activateTitle: "Karte aktivieren", - activateDescription: - "Sie haben die Ehrenamtskarte bereits beantragt und den Aktivierungscode Ihrer digitalen Ehrenamtskarte erhalten? Scannen Sie den Code hier ein.", - verifyTitle: "Gültigkeit prüfen", - verifyDescription: - "Sie möchten die Gültigkeit einer digitalen Ehrenamtskarte prüfen? Scannen Sie den Code hier ein.", - }, - activationCodeScanner: { - title: "Karte aktivieren", - }, - verificationCodeScanner: { - title: "Karte verifizieren", - infoDialogTitle: "So prüfen Sie die Gültigkeit einer Ehrenamtskarte", - positiveVerificationDialogTitle: "Karte ist gültig", - }, - moreActions: { - applyForAnotherCardTitle: "Ehrenamtskarte beantragen oder verlängern", - applyForAnotherCardDescription: "Ihre hinterlegte Karte bleibt erhalten.", - activateAnotherCardTitle: "Anderen Aktivierungscode einscannen", - activateAnotherCardDescription: "Dadurch wird die hinterlegte Karte vom Gerät gelöscht.", - verifyTitle: "Eine digitale Ehrenamtskarte prüfen", - verifyDescription: "Prüfen Sie die Gültigkeit einer digitalen Ehrenamtskarte.", - }, - }, -} - -export default localization diff --git a/frontend/build-configs/nuernberg/index.ts b/frontend/build-configs/nuernberg/index.ts index 11a2393d7..6dd7f404e 100644 --- a/frontend/build-configs/nuernberg/index.ts +++ b/frontend/build-configs/nuernberg/index.ts @@ -1,7 +1,6 @@ import BuildConfigType, {CommonBuildConfigType} from "../types" import publisherText from "./publisherText" import disclaimerText from "./disclaimerText" -import localization from "./localization" export const nuernbergCommon: CommonBuildConfigType = { appName: "Nürnberg-Pass", @@ -31,6 +30,8 @@ export const nuernbergCommon: CommonBuildConfigType = { showcase: "https://api.entitlementcard.app", local: "http://localhost:8000", }, + appLocales: ['de', 'en'], + localeOverridePath: 'assets/nuernberg/l10n', cardBranding: { headerTextColor: "#000000", headerTextFontSize: 9, @@ -54,38 +55,14 @@ export const nuernbergCommon: CommonBuildConfigType = { boxDecorationRadius: 0, }, iconInAboutTab: "assets/nuernberg/body-logo.png", - introSlide1: { - title: "Willkommen!", - description: "Vielen Dank, dass Sie sich die App zum Nürnberg-Pass heruntergeladen haben!", - imagePath: "assets/nuernberg/body-logo.png", - }, - introSlide2: { - title: "Wie kann ich den Nürnberg-Pass beantragen?", - description: - "Im Formular geben Sie Ihre " + - "persönlichen Informationen an. Anschließend wird " + - "der Antrag weitergeleitet und von der zuständigen Stelle bearbeitet.", - imagePath: "assets/nuernberg/intro_slides/apply_for_sozialpass.png", - }, - introSlide3: { - title: "Wo kann ich den Nürnberg-Pass nutzen?", - description: - "Auf der Karte von Nürnberg können Sie alle Akzeptanzstellen" + - " finden. Tippen Sie auf einen Standort, um mehr Informationen " + - "sehen zu können.", - imagePath: "assets/nuernberg/intro_slides/map_zoom.png", - }, - introSlide4: { - title: "Finden Sie Akzeptanzstellen in Ihrer Umgebung!", - description: - "Wir können Ihren Standort auf der Karte anzeigen" + - " und Akzeptanzstellen in Ihrer Umgebung anzeigen. " + - "Wenn Sie diese Hilfen nutzen möchten, benötigen wir Ihre " + - "Zustimmung. Ihr Standort wird nicht gespeichert.", - imagePath: "assets/nuernberg/intro_slides/search_with_location.png", - }, + introSlidesImages: [ + "assets/nuernberg/body-logo.png", + "assets/nuernberg/intro_slides/apply_for_sozialpass.png", + "assets/nuernberg/intro_slides/map_zoom.png", + "assets/nuernberg/intro_slides/search_with_location.png", + ], featureFlags: { - verification: true + verification: true, }, applicationUrl: "https://beantragen.nuernberg.sozialpass.app", publisherAddress: @@ -93,7 +70,7 @@ export const nuernbergCommon: CommonBuildConfigType = { dataPrivacyPolicyUrl: "https://nuernberg.sozialpass.app/data-privacy-policy", publisherText, disclaimerText, - localization, + maxCardAmount: 5, } let nuernberg: BuildConfigType = { @@ -101,15 +78,14 @@ let nuernberg: BuildConfigType = { android: { ...nuernbergCommon, applicationId: "app.entitlementcard.nuernberg", - featureFlags: { - ...nuernbergCommon.featureFlags, + buildFeatures: { excludeLocationPlayServices: false, excludeX86: false, }, }, ios: { ...nuernbergCommon, - bundleIdentifier: "de.nrw.it.ehrensachebayern", + bundleIdentifier: "app.entitlementcard.nuernberg", }, } diff --git a/frontend/build-configs/nuernberg/localization.ts b/frontend/build-configs/nuernberg/localization.ts deleted file mode 100644 index a04732caa..000000000 --- a/frontend/build-configs/nuernberg/localization.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LocalizationType } from "../types" - -const localization: LocalizationType = { - identification: { - noCardView: { - applyTitle: "Beantragen", - applyDescription: - "Sie haben noch keinen Nürnberg-Pass? Hier können Sie Ihren Nürnberg-Pass beantragen.", - activateTitle: "Pass aktivieren", - activateDescription: - "Sie haben den Nürnberg-Pass bereits beantragt und einen Aktivierungscode erhalten? Scannen Sie den Code hier ein.", - verifyTitle: "Gültigkeit prüfen", - verifyDescription: - "Sie möchten die Gültigkeit eines Nürnberg-Passes prüfen? Scannen Sie den Code hier ein.", - }, - activationCodeScanner: { - title: "Pass aktivieren", - }, - verificationCodeScanner: { - title: "Pass verifizieren", - infoDialogTitle: "So prüfen Sie die Gültigkeit eines Nürnberg-Passes", - positiveVerificationDialogTitle: "Pass ist gültig", - }, - moreActions: { - applyForAnotherCardTitle: "Nürnberg-Pass beantragen oder verlängern", - applyForAnotherCardDescription: "Ihr hinterlegter Pass bleibt erhalten.", - activateAnotherCardTitle: "Anderen Aktivierungscode einscannen", - activateAnotherCardDescription: "Dadurch wird der hinterlegte Pass vom Gerät gelöscht.", - verifyTitle: "Einen Nürnberg-Pass prüfen", - verifyDescription: "Prüfen Sie die Gültigkeit eines Nürnberg-Passes.", - }, - }, -} - -export default localization diff --git a/frontend/build-configs/types.ts b/frontend/build-configs/types.ts index 186e92b72..e7085380b 100644 --- a/frontend/build-configs/types.ts +++ b/frontend/build-configs/types.ts @@ -13,41 +13,6 @@ export type ThemeType = { primaryDark: string } -type SlideType = { - title: string - description: string - imagePath: string -} - -export type LocalizationType = { - identification: { - noCardView: { - applyTitle: string - applyDescription: string - activateTitle: string - activateDescription: string - verifyTitle: string - verifyDescription: string - } - activationCodeScanner: { - title: string - } - verificationCodeScanner: { - title: string - infoDialogTitle: string - positiveVerificationDialogTitle: string - } - moreActions: { - applyForAnotherCardTitle: string - applyForAnotherCardDescription: string - activateAnotherCardTitle: string - activateAnotherCardDescription: string - verifyTitle: string - verifyDescription: string - } - } -} - export type CommonBuildConfigType = { appName: string appIcon: string @@ -71,6 +36,8 @@ export type CommonBuildConfigType = { production: string local: string } + appLocales: string[] + localeOverridePath: string | null cardBranding: { headerTextColor: string headerColor: string @@ -104,10 +71,7 @@ export type CommonBuildConfigType = { boxDecorationRadius: number } iconInAboutTab: string - introSlide1: SlideType, - introSlide2: SlideType, - introSlide3: SlideType, - introSlide4: SlideType + introSlidesImages: [string, string, string, string], theme: ThemeType categories: number[] featureFlags: FeatureFlagsType @@ -116,13 +80,13 @@ export type CommonBuildConfigType = { publisherAddress: string publisherText: string disclaimerText: string - localization: LocalizationType + maxCardAmount: number } export type AndroidBuildConfigType = CommonBuildConfigType & { // Shows the app icon as splash screen on app start. applicationId: string - featureFlags: { + buildFeatures: { excludeLocationPlayServices: boolean excludeX86: boolean } diff --git a/frontend/build.yaml b/frontend/build.yaml index a5f599b57..417a551a2 100644 --- a/frontend/build.yaml +++ b/frontend/build.yaml @@ -8,10 +8,11 @@ targets: - schema.graphql - card.proto - lib/build_config/build_config.yaml + - assets/l10n/* builders: df_build_config: generate_for: - include: + include: - "lib/build_config/build_config.yaml" enabled: True df_protobuf: @@ -34,3 +35,12 @@ targets: name: MultipartFile imports: - 'package:http/http.dart' + slang_build_runner: + options: + base_locale: de + input_directory: assets/l10n + input_file_pattern: .json + output_file_name: translations.g.dart + output_directory: lib/l10n + translation_overrides: true + string_interpolation: double_braces diff --git a/frontend/lib/about/about_page.dart b/frontend/lib/about/about_page.dart index 1b14bdd24..4e41af45f 100644 --- a/frontend/lib/about/about_page.dart +++ b/frontend/lib/about/about_page.dart @@ -1,7 +1,9 @@ import 'package:ehrenamtskarte/about/backend_switch_dialog.dart'; import 'package:ehrenamtskarte/about/content_tile.dart'; import 'package:ehrenamtskarte/about/dev_settings_view.dart'; +import 'package:ehrenamtskarte/about/language_change.dart'; import 'package:ehrenamtskarte/about/license_page.dart'; +import 'package:ehrenamtskarte/about/section.dart'; import 'package:ehrenamtskarte/about/texts.dart'; import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/configuration.dart'; @@ -10,8 +12,11 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class AboutPage extends StatefulWidget { final countToEnableSwitch = 10; + const AboutPage({super.key}); @override @@ -24,7 +29,6 @@ class AboutPageState extends State { @override Widget build(BuildContext context) { final config = Configuration.of(context); - return FutureBuilder( future: PackageInfo.fromPlatform(), builder: (context, snapshot) { @@ -67,14 +71,14 @@ class AboutPageState extends State { child: Column( children: [ Center( - child: Text('Herausgeber', style: Theme.of(context).textTheme.titleSmall), + child: Text(t.about.publisher, style: Theme.of(context).textTheme.titleSmall), ), Padding( padding: const EdgeInsets.only(left: 10, right: 10, top: 16, bottom: 16), child: Text(buildConfig.publisherAddress, style: Theme.of(context).textTheme.bodyLarge), ), Text( - 'Mehr Informationen', + t.about.moreInformation, style: Theme.of(context) .textTheme .bodyMedium @@ -87,58 +91,80 @@ class AboutPageState extends State { Navigator.push( context, AppRoute( - builder: (context) => ContentPage(title: 'Herausgeber', children: getPublisherText(context)), + builder: (context) => ContentPage(title: t.about.publisher, children: getPublisherText(context)), ), ); }, ), + if (buildConfig.appLocales.length > 1) + Column(children: [ + const Divider( + height: 1, + thickness: 1, + ), + Section( + headline: t.about.settingsTitle, + children: [ + ContentTile(icon: Icons.language, title: t.about.languageChange, children: [LanguageChange()]), + ], + ), + ]), const Divider( height: 1, thickness: 1, ), - const SizedBox(height: 20), - ContentTile(icon: Icons.copyright, title: 'Lizenz', children: getCopyrightText(context)), - ListTile( - leading: const Icon(Icons.privacy_tip_outlined), - title: const Text('Datenschutzerklärung'), - onTap: () => launchUrlString(buildConfig.dataPrivacyPolicyUrl, mode: LaunchMode.externalApplication), - ), - ContentTile( - icon: Icons.info_outline, - title: 'Haftung, Haftungsausschluss und Impressum', - children: getDisclaimerText(context), - ), - ListTile( - leading: const Icon(Icons.book_outlined), - title: const Text('Software-Bibliotheken'), - onTap: () { - Navigator.push( - context, - AppRoute( - builder: (context) => const CustomLicensePage(), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.code_outlined), - title: const Text('Quellcode der App'), - onTap: () { - launchUrlString( - 'https://github.com/digitalfabrik/entitlementcard', - mode: LaunchMode.externalApplication, - ); - }, - ), - if (config.showDevSettings) + Section(headline: t.about.infoTitle, children: [ + ContentTile(icon: Icons.copyright, title: t.about.licenses(n: 1), children: getCopyrightText(context)), ListTile( - leading: const Icon(Icons.build), - title: const Text('Entwickleroptionen'), - onTap: () => showDialog( - context: context, - builder: (context) => - const SimpleDialog(title: Text('Entwickleroptionen'), children: [DevSettingsView()]), - ), + leading: const Icon(Icons.privacy_tip_outlined), + title: Text(t.about.privacyDeclaration), + onTap: () => launchUrlString(buildConfig.dataPrivacyPolicyUrl, mode: LaunchMode.externalApplication), + ), + ContentTile( + icon: Icons.info_outline, + title: t.about.disclaimer, + children: getDisclaimerText(context), + ), + ListTile( + leading: const Icon(Icons.book_outlined), + title: Text(t.about.dependencies), + onTap: () { + Navigator.push( + context, + AppRoute( + builder: (context) => const CustomLicensePage(), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.code_outlined), + title: Text(t.about.sourceCode), + onTap: () { + launchUrlString( + 'https://github.com/digitalfabrik/entitlementcard', + mode: LaunchMode.externalApplication, + ); + }, + ), + ]), + if (config.showDevSettings) + Column( + children: [ + const Divider( + height: 1, + thickness: 1, + ), + ListTile( + leading: const Icon(Icons.build), + title: Text(t.about.developmentOptions), + onTap: () => showDialog( + context: context, + builder: (context) => + SimpleDialog(title: Text(t.about.developmentOptions), children: [DevSettingsView()]), + ), + ) + ], ) ]; } else { diff --git a/frontend/lib/about/backend_switch_dialog.dart b/frontend/lib/about/backend_switch_dialog.dart index 60603ea62..ba04b5143 100644 --- a/frontend/lib/about/backend_switch_dialog.dart +++ b/frontend/lib/about/backend_switch_dialog.dart @@ -84,8 +84,8 @@ class BackendSwitchDialogState extends State { void clearData() { final settings = Provider.of(context, listen: false); - final card = Provider.of(context, listen: false); + final userCodesModel = Provider.of(context, listen: false); settings.clearSettings(); - card.removeCode(); + userCodesModel.removeCodes(); } } diff --git a/frontend/lib/about/dev_settings_view.dart b/frontend/lib/about/dev_settings_view.dart index 8612fb78c..8b2c00945 100644 --- a/frontend/lib/about/dev_settings_view.dart +++ b/frontend/lib/about/dev_settings_view.dart @@ -59,15 +59,16 @@ class DevSettingsView extends StatelessWidget { child: Column( children: [ ListTile( - title: const Text('Reset card'), - onTap: () => _resetEakData(context), + title: const Text('Reset cards'), + onTap: () => _resetEakData(context, userCodeModel), ), ListTile( title: const Text('Set (invalid) sample card'), onTap: () => _setSampleCard(context), ), ListTile( - title: const Text('Set base64 card'), + title: Text('Set base64 card (Limit: ${buildConfig.maxCardAmount})'), + enabled: !hasReachedCardLimit(userCodeModel.userCodes), onTap: () => _showRawCardInput(context), ), ListTile( @@ -76,12 +77,14 @@ class DevSettingsView extends StatelessWidget { ), ListTile( title: const Text('Set expired last card verification'), - onTap: () => _setExpiredLastVerification(context), + onTap: () => _setExpiredLastVerifications(context), ), ListTile( - title: const Text('Trigger self-verification'), - onTap: () => selfVerifyCard(userCodeModel, Configuration.of(context).projectId, client), - ), + title: const Text('Trigger self-verification'), + onTap: () => { + for (final userCode in userCodeModel.userCodes) + {selfVerifyCard(context, userCode, Configuration.of(context).projectId, client)} + }), ListTile( title: const Text('Log sample exception'), onTap: () => log('Sample exception.', error: Exception('Sample exception...')), @@ -101,8 +104,8 @@ class DevSettingsView extends StatelessWidget { ); } - Future _resetEakData(BuildContext context) async { - Provider.of(context, listen: false).removeCode(); + Future _resetEakData(BuildContext context, UserCodeModel userCodesModel) async { + userCodesModel.removeCodes(); } DynamicUserCode _determineUserCode(String projectId) { @@ -123,7 +126,7 @@ class DevSettingsView extends StatelessWidget { } Future _setSampleCard(BuildContext context) async { - Provider.of(context, listen: false).setCode(_determineUserCode(buildConfig.projectId.local)); + Provider.of(context, listen: false).insertCode(_determineUserCode(buildConfig.projectId.local)); } Future _showRawCardInput(BuildContext context) async { @@ -168,7 +171,7 @@ class DevSettingsView extends StatelessWidget { Future _activateCard(BuildContext context, String base64qrcode) async { final messengerState = ScaffoldMessenger.of(context); - final provider = Provider.of(context, listen: false); + final userCodesModel = Provider.of(context, listen: false); final client = GraphQLProvider.of(context).value; final projectId = Configuration.of(context).projectId; try { @@ -193,7 +196,7 @@ class DevSettingsView extends StatelessWidget { ..info = activationCode.info ..pepper = activationCode.pepper ..totpSecret = totpSecret; - provider.setCode(userCode); + userCodesModel.insertCode(userCode); break; case ActivationState.failed: await QrParsingErrorDialog.showErrorDialog( @@ -233,15 +236,24 @@ class DevSettingsView extends StatelessWidget { ); } + void _setExpiredLastVerifications(BuildContext context) { + final userCodesModel = Provider.of(context, listen: false); + if (userCodesModel.userCodes.isNotEmpty) { + List userCodes = userCodesModel.userCodes; + for (final userCode in userCodes) { + _setExpiredLastVerification(context, userCode); + } + } + } + // This is used to check the invalidation of a card because the verification with the backend couldn't be done lately (1 week plus UTC tolerance) - void _setExpiredLastVerification(BuildContext context) { - final provider = Provider.of(context, listen: false); - final DynamicUserCode userCode = provider.userCode!; + void _setExpiredLastVerification(BuildContext context, DynamicUserCode userCode) { + final userCodesModel = Provider.of(context, listen: false); final CardVerification cardVerification = CardVerification() ..verificationTimeStamp = secondsSinceEpoch(DateTime.now().toUtc().subtract(Duration(seconds: cardValidationExpireSeconds + 3600))) ..cardValid = true; - provider.setCode(DynamicUserCode() + userCodesModel.updateCode(DynamicUserCode() ..info = userCode.info ..ecSignature = userCode.ecSignature ..pepper = userCode.pepper diff --git a/frontend/lib/about/language_change.dart b/frontend/lib/about/language_change.dart new file mode 100644 index 000000000..eb5eb2b13 --- /dev/null +++ b/frontend/lib/about/language_change.dart @@ -0,0 +1,40 @@ +import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; +import 'package:flutter/material.dart'; + +Map languages = {'en': 'Englisch', 'de': 'Deutsch'}; + +class LanguageChange extends StatelessWidget { + const LanguageChange({super.key}); + + @override + Widget build(BuildContext context) { + return Column(children: [ + ...buildConfig.appLocales.map((item) => DecoratedBox( + decoration: BoxDecoration( + color: LocaleSettings.currentLocale.languageCode == item + ? Theme.of(context).colorScheme.surfaceVariant + : null), + child: ListTile( + title: Text( + languages[item]!, + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + onTap: () => switchLanguage(context, item)))) + ]); + } + + switchLanguage(BuildContext context, String language) { + final messengerState = ScaffoldMessenger.of(context); + LocaleSettings.setLocaleRaw(language); + messengerState.showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).colorScheme.primary, + content: + Text(t.about.languageChangeSuccessful, style: TextStyle(color: Theme.of(context).colorScheme.background)), + ), + ); + Navigator.pop(context); + } +} diff --git a/frontend/lib/about/license_page.dart b/frontend/lib/about/license_page.dart index bc0837ca7..6c9c85182 100644 --- a/frontend/lib/about/license_page.dart +++ b/frontend/lib/about/license_page.dart @@ -6,6 +6,8 @@ import 'package:ehrenamtskarte/widgets/top_loading_spinner.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class CustomLicenseEntry { final String packageName; final List> licenseParagraphs; @@ -51,7 +53,7 @@ class CustomLicensePage extends StatelessWidget { return CustomScrollView( slivers: [ - const CustomSliverAppBar(title: 'Lizenzen'), + CustomSliverAppBar(title: t.about.licenses(n: licenses.length)), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { @@ -59,7 +61,7 @@ class CustomLicensePage extends StatelessWidget { final paragraphs = license.licenseParagraphs; return ListTile( title: Text(license.packageName), - subtitle: Text('${paragraphs.length} Lizenzen'), + subtitle: Text(t.about.numberLicenses(n: paragraphs.length)), onTap: () { Navigator.push( context, diff --git a/frontend/lib/about/section.dart b/frontend/lib/about/section.dart new file mode 100644 index 000000000..652727715 --- /dev/null +++ b/frontend/lib/about/section.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class Section extends StatelessWidget { + final String headline; + final List children; + + const Section({super.key, required this.headline, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: 16, left: 16, right: 16), + child: Text(headline, + style: Theme.of(context) + .textTheme + .bodySmall + ?.merge(TextStyle(color: Theme.of(context).colorScheme.secondary))), + ), + Column(children: children), + const SizedBox(height: 10), + ], + ); + } +} diff --git a/frontend/lib/app.dart b/frontend/lib/app.dart index fb60773e3..ac8ebaecd 100644 --- a/frontend/lib/app.dart +++ b/frontend/lib/app.dart @@ -5,6 +5,7 @@ import 'package:ehrenamtskarte/configuration/settings_model.dart'; import 'package:ehrenamtskarte/graphql/configured_graphql_provider.dart'; import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:ehrenamtskarte/intro_slides/intro_screen.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; import 'package:ehrenamtskarte/themes.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -66,13 +67,13 @@ class App extends StatelessWidget { darkTheme: darkTheme, themeMode: ThemeMode.system, debugShowCheckedModeBanner: false, - localizationsDelegates: const [ + localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - supportedLocales: const [Locale('de')], - locale: const Locale('de'), + supportedLocales: buildConfig.appLocales.map((locale) => Locale(locale)), + locale: TranslationProvider.of(context).flutterLocale, initialRoute: initialRoute, routes: routes, ), diff --git a/frontend/lib/category_assets.dart b/frontend/lib/category_assets.dart index c1a981110..49981506c 100644 --- a/frontend/lib/category_assets.dart +++ b/frontend/lib/category_assets.dart @@ -1,6 +1,5 @@ -import 'dart:ui'; - -import 'package:meta/meta.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; +import 'package:flutter/material.dart'; const test = Color(0xff5f5384); @@ -30,157 +29,159 @@ class CategoryAsset { int get hashCode => id.hashCode; } -const List categoryAssets = [ - CategoryAsset( - id: 0, - name: 'Auto/Zweirad', - shortName: 'Mobilität', - icon: 'assets/category_icons/0.svg', - detailIcon: 'assets/detail_headers/0_auto.svg', - color: Color(0xffE89600), - ), - CategoryAsset( - id: 1, - name: 'Multimedia', - shortName: 'Multimedia', - icon: 'assets/category_icons/1.svg', - detailIcon: 'assets/detail_headers/1_multimedia.svg', - color: Color(0xFFFA0000), - ), - CategoryAsset( - id: 2, - name: 'Gesundheit/Sport/Wellness', - shortName: 'Gesundheit', - icon: 'assets/category_icons/2.svg', - detailIcon: 'assets/detail_headers/2_sport.svg', - color: Color(0xFFE500D3), - ), - CategoryAsset( - id: 3, - name: 'Bildung/Kultur/Unterhaltung', - shortName: 'Kultur', - icon: 'assets/category_icons/3.svg', - detailIcon: 'assets/detail_headers/3_kultur.svg', - color: Color(0xFF7500EB), - ), - CategoryAsset( - id: 4, - name: 'Dienstleistungen/Finanzen', - shortName: 'Dienstleistung', - icon: 'assets/category_icons/4.svg', - detailIcon: 'assets/detail_headers/4_finanzen.svg', - color: Color(0xFF515151), - ), - CategoryAsset( - id: 5, - name: 'Mode/Beauty', - shortName: 'Mode', - icon: 'assets/category_icons/5.svg', - detailIcon: 'assets/detail_headers/5_mode.svg', - color: Color(0xFF6EBE00), - ), - CategoryAsset( - id: 6, - name: 'Wohnen/Haus/Garten', - shortName: 'Einrichtung', - icon: 'assets/category_icons/6.svg', - detailIcon: 'assets/detail_headers/6_haus.svg', - color: Color(0xFF00D0C7), - ), - CategoryAsset( - id: 7, - name: 'Freizeit/Reise/Unterkünfte', - shortName: 'Freizeit', - icon: 'assets/category_icons/7.svg', - detailIcon: 'assets/detail_headers/7_freizeit.svg', - color: Color(0xFF007CE8), - ), - CategoryAsset( - id: 8, - name: 'Essen/Trinken/Gastronomie', - shortName: 'Gastronomie', - icon: 'assets/category_icons/8.svg', - detailIcon: 'assets/detail_headers/8_essen.svg', - color: Color(0xFF197489), - ), - CategoryAsset( - id: 9, - name: 'Anderes', - shortName: 'Anderes', - icon: 'assets/category_icons/9.svg', - detailIcon: null, - color: Color(0xFFc51162), - ), - CategoryAsset( - id: 10, - name: 'Mittagstische', - shortName: 'Mittagstische', - icon: 'assets/category_icons/10.svg', - detailIcon: 'assets/detail_headers/10_mittagstische.svg', - color: Color(0xFF197489), - ), - CategoryAsset( - id: 11, - name: 'Kleidung/Gebrauchtes', - shortName: 'Kleidung', - icon: 'assets/category_icons/11.svg', - detailIcon: 'assets/detail_headers/11_kleidung.svg', - color: Color(0xFF6EBE00), - ), - CategoryAsset( - id: 12, - name: 'Kultur/Museen/Freizeit', - shortName: 'Kultur', - icon: 'assets/category_icons/12.svg', - detailIcon: 'assets/detail_headers/12_kultur.svg', - color: Color(0xFF7500EB), - ), - CategoryAsset( - id: 13, - name: 'Bildung', - shortName: 'Bildung', - icon: 'assets/category_icons/13.svg', - detailIcon: null, - color: Color(0xFFd100cc), - ), - CategoryAsset( - id: 14, - name: 'Kinos/Theater/Konzerte', - shortName: 'Schauspiel', - icon: 'assets/category_icons/14.svg', - detailIcon: null, - color: Color(0xFFc51162), - ), - CategoryAsset( - id: 15, - name: 'Apotheken/Gesundheit', - shortName: 'Apotheken', - icon: 'assets/category_icons/15.svg', - detailIcon: null, - color: Color(0xFF007be0), - ), - CategoryAsset( - id: 16, - name: 'Digitale Teilhabe', - shortName: 'Digitale Teilhabe', - icon: 'assets/category_icons/16.svg', - detailIcon: 'assets/detail_headers/16_teilhabe.svg', - color: Color(0xFFFA0000), - ), - CategoryAsset( - id: 17, - name: 'Sport/Bewegung/Tanz', - shortName: 'Sport', - icon: 'assets/category_icons/17.svg', - detailIcon: 'assets/detail_headers/17_sport.svg', - color: Color(0xFF00ccc6), - ), - CategoryAsset( - id: 18, - name: 'Mobilität', - shortName: 'Mobilität', - icon: 'assets/category_icons/18.svg', - detailIcon: 'assets/detail_headers/18_mobilitaet.svg', - color: Color(0xffE89600), - ), -]; +List categoryAssets(BuildContext context) { + return [ + CategoryAsset( + id: 0, + name: t.category.mobilityLong, + shortName: t.category.mobility, + icon: 'assets/category_icons/0.svg', + detailIcon: 'assets/detail_headers/0_auto.svg', + color: Color(0xffE89600), + ), + CategoryAsset( + id: 1, + name: t.category.media, + shortName: t.category.media, + icon: 'assets/category_icons/1.svg', + detailIcon: 'assets/detail_headers/1_multimedia.svg', + color: Color(0xFFFA0000), + ), + CategoryAsset( + id: 2, + name: t.category.healthLong, + shortName: t.category.health, + icon: 'assets/category_icons/2.svg', + detailIcon: 'assets/detail_headers/2_sport.svg', + color: Color(0xFFE500D3), + ), + CategoryAsset( + id: 3, + name: t.category.cultureLong, + shortName: t.category.culture, + icon: 'assets/category_icons/3.svg', + detailIcon: 'assets/detail_headers/3_kultur.svg', + color: Color(0xFF7500EB), + ), + CategoryAsset( + id: 4, + name: t.category.servicesLong, + shortName: t.category.services, + icon: 'assets/category_icons/4.svg', + detailIcon: 'assets/detail_headers/4_finanzen.svg', + color: Color(0xFF515151), + ), + CategoryAsset( + id: 5, + name: t.category.fashionLong, + shortName: t.category.fashion, + icon: 'assets/category_icons/5.svg', + detailIcon: 'assets/detail_headers/5_mode.svg', + color: Color(0xFF6EBE00), + ), + CategoryAsset( + id: 6, + name: t.category.livingLong, + shortName: t.category.living, + icon: 'assets/category_icons/6.svg', + detailIcon: 'assets/detail_headers/6_haus.svg', + color: Color(0xFF00D0C7), + ), + CategoryAsset( + id: 7, + name: t.category.leisureLong, + shortName: t.category.leisure, + icon: 'assets/category_icons/7.svg', + detailIcon: 'assets/detail_headers/7_freizeit.svg', + color: Color(0xFF007CE8), + ), + CategoryAsset( + id: 8, + name: t.category.foodLong, + shortName: t.category.food, + icon: 'assets/category_icons/8.svg', + detailIcon: 'assets/detail_headers/8_essen.svg', + color: Color(0xFF197489), + ), + CategoryAsset( + id: 9, + name: t.category.other, + shortName: t.category.other, + icon: 'assets/category_icons/9.svg', + detailIcon: null, + color: Color(0xFFc51162), + ), + CategoryAsset( + id: 10, + name: t.category.lunchTables, + shortName: t.category.lunchTables, + icon: 'assets/category_icons/10.svg', + detailIcon: 'assets/detail_headers/10_mittagstische.svg', + color: Color(0xFF197489), + ), + CategoryAsset( + id: 11, + name: t.category.clothingLong, + shortName: t.category.clothing, + icon: 'assets/category_icons/11.svg', + detailIcon: 'assets/detail_headers/11_kleidung.svg', + color: Color(0xFF6EBE00), + ), + CategoryAsset( + id: 12, + name: t.category.cultureLongNuernberg, + shortName: t.category.culture, + icon: 'assets/category_icons/12.svg', + detailIcon: 'assets/detail_headers/12_kultur.svg', + color: Color(0xFF7500EB), + ), + CategoryAsset( + id: 13, + name: t.category.education, + shortName: t.category.education, + icon: 'assets/category_icons/13.svg', + detailIcon: null, + color: Color(0xFFd100cc), + ), + CategoryAsset( + id: 14, + name: t.category.moviesLong, + shortName: t.category.movies, + icon: 'assets/category_icons/14.svg', + detailIcon: null, + color: Color(0xFFc51162), + ), + CategoryAsset( + id: 15, + name: t.category.pharmaciesLong, + shortName: t.category.pharmacies, + icon: 'assets/category_icons/15.svg', + detailIcon: null, + color: Color(0xFF007be0), + ), + CategoryAsset( + id: 16, + name: t.category.digitalParticipation, + shortName: t.category.digitalParticipation, + icon: 'assets/category_icons/16.svg', + detailIcon: 'assets/detail_headers/16_teilhabe.svg', + color: Color(0xFFFA0000), + ), + CategoryAsset( + id: 17, + name: t.category.sportsLong, + shortName: t.category.sports, + icon: 'assets/category_icons/17.svg', + detailIcon: 'assets/detail_headers/17_sport.svg', + color: Color(0xFF00ccc6), + ), + CategoryAsset( + id: 18, + name: t.category.mobility, + shortName: t.category.mobility, + icon: 'assets/category_icons/18.svg', + detailIcon: 'assets/detail_headers/18_mobilitaet.svg', + color: Color(0xffE89600), + ), + ]; +} diff --git a/frontend/lib/home/app_flow.dart b/frontend/lib/home/app_flow.dart index 613dc5597..18a99a942 100644 --- a/frontend/lib/home/app_flow.dart +++ b/frontend/lib/home/app_flow.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; class AppFlow { final Widget widget; final IconData iconData; - final String title; + final String Function(BuildContext) getTitle; final GlobalKey navigatorKey; - AppFlow(this.widget, this.iconData, this.title, this.navigatorKey); + AppFlow(this.widget, this.iconData, this.getTitle, this.navigatorKey); } diff --git a/frontend/lib/home/home_page.dart b/frontend/lib/home/home_page.dart index 7361cc199..c306cf0be 100644 --- a/frontend/lib/home/home_page.dart +++ b/frontend/lib/home/home_page.dart @@ -11,6 +11,8 @@ import 'package:ehrenamtskarte/search/search_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + const mapTabIndex = 0; class HomePage extends StatefulWidget { @@ -37,23 +39,24 @@ class HomePageState extends State { selectAcceptingStore: (id) => setState(() => selectedAcceptingStoreId = id), ), Icons.map_outlined, - 'Karte', + (BuildContext context) => t.map.title, GlobalKey(debugLabel: 'Map tab key'), ), AppFlow( const SearchPage(), Icons.search_outlined, - 'Suche', + (BuildContext context) => t.search.title, GlobalKey(debugLabel: 'Search tab key'), ), if (buildConfig.featureFlags.verification) AppFlow( - const IdentificationPage(title: 'Ausweisen'), - Icons.remove_red_eye_outlined, - 'Ausweisen', + IdentificationPage(), + Icons.credit_card, + (BuildContext context) => t.identification.title, GlobalKey(debugLabel: 'Auth tab key'), ), - AppFlow(const AboutPage(), Icons.info_outline, 'Über', GlobalKey(debugLabel: 'About tab key')), + AppFlow(const AboutPage(), buildConfig.appLocales.length > 1 ? Icons.menu : Icons.info_outline, + (BuildContext context) => t.about.title, GlobalKey(debugLabel: 'About tab key')), ]; } @@ -95,7 +98,7 @@ class HomePageState extends State { currentIndex: _currentTabIndex, backgroundColor: theme.colorScheme.surfaceVariant, items: appFlows - .map((appFlow) => BottomNavigationBarItem(icon: Icon(appFlow.iconData), label: appFlow.title)) + .map((appFlow) => BottomNavigationBarItem(icon: Icon(appFlow.iconData), label: appFlow.getTitle(context))) .toList(), onTap: _onTabTapped, type: BottomNavigationBarType.fixed, diff --git a/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart b/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart index 2232874c9..b6543cf63 100644 --- a/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart +++ b/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/configuration.dart'; import 'package:ehrenamtskarte/graphql/graphql_api.graphql.dart'; import 'package:ehrenamtskarte/identification/activation_workflow/activate_code.dart'; import 'package:ehrenamtskarte/identification/activation_workflow/activation_code_parser.dart'; import 'package:ehrenamtskarte/identification/activation_workflow/activation_exception.dart'; +import 'package:ehrenamtskarte/identification/activation_workflow/activation_existing_card_dialog.dart'; import 'package:ehrenamtskarte/identification/activation_workflow/activation_overwrite_existing_dialog.dart'; import 'package:ehrenamtskarte/identification/connection_failed_dialog.dart'; import 'package:ehrenamtskarte/identification/qr_code_scanner/qr_code_processor.dart'; @@ -24,15 +24,17 @@ import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class ActivationCodeScannerPage extends StatelessWidget { - const ActivationCodeScannerPage({super.key}); + final VoidCallback moveToLastCard; + const ActivationCodeScannerPage({super.key, required this.moveToLastCard}); @override Widget build(BuildContext context) { - final localization = buildConfig.localization.identification.activationCodeScanner; return Column( children: [ - CustomAppBar(title: localization.title), + CustomAppBar(title: t.identification.activateTitle), Expanded( child: QrCodeScannerPage( onCodeScanned: (code) async => _onCodeScanned(context, code), @@ -50,32 +52,20 @@ class ActivationCodeScannerPage extends StatelessWidget { final activationCode = const ActivationCodeParser().parseQrCodeContent(code); await _activateCode(context, activationCode); - } on ActivationDidNotOverwriteExisting catch (e) { - await showError(e.toString(), null); + } on ActivationDidNotOverwriteExisting catch (_) { + await showError(t.identification.cardAlreadyActivated, null); } on QrCodeFieldMissingException catch (e) { - await showError( - 'Der Inhalt des eingescannten Codes ist unvollständig. ' - '(Fehlercode: ${e.missingFieldName}Missing)', - null); + await showError(t.identification.codeInvalidMissing(missing: e.missingFieldName), null); } on QrCodeWrongTypeException catch (_) { - await showError('Der eingescannte Code kann nicht in der App gespeichert werden.', null); + await showError(t.identification.codeSavingFailed, null); } on CardExpiredException catch (e) { - final dateFormat = DateFormat('dd.MM.yyyy'); - await showError( - 'Der eingescannte Code ist bereits am ' - '${dateFormat.format(e.expiry)} abgelaufen.', - null); - } on ServerCardActivationException catch (e, stackTrace) { - String errorMessage = 'Der eingescannte Code konnte nicht aktiviert ' - 'werden, da die Kommunikation mit dem Server fehlschlug. ' - 'Bitte prüfen Sie Ihre Internetverbindung.'; - await ConnectionFailedDialog.show( - context, - errorMessage, - ); - await reportError(errorMessage + e.toString(), stackTrace); - } on Exception catch (e, stackTrace) { - await showError('Ein unerwarteter Fehler ist aufgetreten.', stackTrace); + final expirationDate = DateFormat('dd.MM.yyyy').format(e.expiry); + await showError(t.identification.codeExpired(expirationDate: expirationDate), null); + } on ServerCardActivationException catch (_) { + await ConnectionFailedDialog.show(context, t.identification.codeActivationFailedConnection); + } on Exception catch (e, stacktrace) { + debugPrintStack(stackTrace: stacktrace, label: e.toString()); + await showError(t.identification.codeUnknownError, null); } } @@ -86,7 +76,7 @@ class ActivationCodeScannerPage extends StatelessWidget { ]) async { final client = GraphQLProvider.of(context).value; final projectId = Configuration.of(context).projectId; - final provider = Provider.of(context, listen: false); + final userCodesModel = Provider.of(context, listen: false); final activationSecretBase64 = const Base64Encoder().convert(activationCode.activationSecret); final cardInfoBase64 = activationCode.info.hash(activationCode.pepper); @@ -107,26 +97,34 @@ class ActivationCodeScannerPage extends StatelessWidget { throw const ActivationInvalidTotpSecretException(); } final totpSecret = const Base64Decoder().convert(activationResult.totpSecret!); - debugPrint('Card Activation: Successfully activated.'); - provider.setCode(DynamicUserCode() + DynamicUserCode userCode = DynamicUserCode() ..info = activationCode.info ..pepper = activationCode.pepper ..totpSecret = totpSecret ..cardVerification = (CardVerification() ..cardValid = true - ..verificationTimeStamp = secondsSinceEpoch(DateTime.parse(activationResult.activationTimeStamp)))); + ..verificationTimeStamp = secondsSinceEpoch(DateTime.parse(activationResult.activationTimeStamp))); + + userCodesModel.insertCode(userCode); + moveToLastCard(); + debugPrint('Card Activation: Successfully activated.'); + break; case ActivationState.failed: await QrParsingErrorDialog.showErrorDialog( context, - 'Der eingescannte Code ist ungültig.', + t.identification.codeInvalid, ); break; case ActivationState.didNotOverwriteExisting: if (overwriteExisting) { throw const ActivationDidNotOverwriteExisting(); } + if (isAlreadyInList(userCodesModel.userCodes, activationCode.info)) { + await ActivationExistingCardDialog.showExistingCardDialog(context); + break; + } debugPrint( 'Card Activation: Card had been activated already and was not overwritten. Waiting for user feedback.'); if (await ActivationOverwriteExistingDialog.showActivationOverwriteExistingDialog(context)) { @@ -134,7 +132,7 @@ class ActivationCodeScannerPage extends StatelessWidget { } break; default: - String errorMessage = 'Die Aktivierung befindet sich in einem ungültigen Zustand.'; + const errorMessage = 'Die Aktivierung befindet sich in einem ungültigen Zustand.'; reportError(errorMessage, null); throw ServerCardActivationException(errorMessage); } diff --git a/frontend/lib/identification/activation_workflow/activation_exception.dart b/frontend/lib/identification/activation_workflow/activation_exception.dart index fb9233da5..bd28d96b7 100644 --- a/frontend/lib/identification/activation_workflow/activation_exception.dart +++ b/frontend/lib/identification/activation_workflow/activation_exception.dart @@ -1,15 +1,9 @@ import 'package:ehrenamtskarte/identification/activation_workflow/activate_code.dart'; class ActivationInvalidTotpSecretException extends ServerCardActivationException { - const ActivationInvalidTotpSecretException() - : super('Der Server konnte kein TotpSecret für den eingescannten QRCode generieren.'); + const ActivationInvalidTotpSecretException() : super('Server failed to create totp secret for the scanned code.'); } class ActivationDidNotOverwriteExisting implements Exception { const ActivationDidNotOverwriteExisting() : super(); - - @override - String toString() { - return 'Der eingescannte QRCode wurde bereits aktiviert.'; - } } diff --git a/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart b/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart new file mode 100644 index 000000000..630dd5521 --- /dev/null +++ b/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class ActivationExistingCardDialog extends StatelessWidget { + const ActivationExistingCardDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Diese Karte existiert bereits', style: TextStyle(fontSize: 18)), + content: SingleChildScrollView( + child: ListBody( + children: const [ + Text( + 'Diese Karte ist bereits auf ihrem Gerät aktiv.', + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ) + ], + ); + } + + /// Returns true, if the user wants to activate an existing card + static Future showExistingCardDialog(BuildContext context) async { + return await showDialog( + context: context, + builder: (context) => ActivationExistingCardDialog(), + ) ?? + false; + } +} diff --git a/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart b/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart index ca322644e..d40be1428 100644 --- a/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart +++ b/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart @@ -1,30 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class ActivationOverwriteExistingDialog extends StatelessWidget { const ActivationOverwriteExistingDialog({super.key}); @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Karte auf diesem Gerät aktivieren?', style: TextStyle(fontSize: 18)), + title: Text(t.identification.activateCurrentDeviceTitle, style: TextStyle(fontSize: 18)), content: SingleChildScrollView( child: ListBody( - children: const [ - Text( - 'Ihre Karte ist bereits auf einem anderen Gerät aktiviert. Wenn Sie Ihre Karte auf diesem Gerät aktivieren, wird sie auf Ihrem anderen Gerät automatisch deaktiviert.', - ), + children: [ + Text(t.identification.activateCurrentDeviceDescription), ], ), ), actions: [ TextButton( - child: const Text('Abbrechen'), + child: Text(t.common.cancel), onPressed: () { Navigator.of(context).pop(false); }, ), TextButton( - child: const Text('Aktivieren'), + child: Text(t.identification.activate), onPressed: () { Navigator.of(context).pop(true); }, diff --git a/frontend/lib/identification/card_detail_view/card_carousel.dart b/frontend/lib/identification/card_detail_view/card_carousel.dart new file mode 100644 index 000000000..9647306b1 --- /dev/null +++ b/frontend/lib/identification/card_detail_view/card_carousel.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:carousel_slider/carousel_slider.dart'; + +class CardCarousel extends StatefulWidget { + final List cards; + final int cardIndex; + final Function(int index) updateIndex; + final CarouselController carouselController; + + const CardCarousel( + {super.key, + required this.cards, + required this.cardIndex, + required this.updateIndex, + required this.carouselController}); + + @override + CardCarouselState createState() => CardCarouselState(); +} + +// Default bottomNavigationBarHeight in flutter +// https://api.flutter.dev/flutter/material/NavigationBar/height.html +final double bottomNavigationBarHeight = 80; + +class CardCarouselState extends State { + @override + Widget build(BuildContext context) { + final int cardAmount = widget.cards.length; + final double indicatorHeight = cardAmount > 1 ? 16 : 0; + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: CarouselSlider( + items: widget.cards, + carouselController: widget.carouselController, + options: CarouselOptions( + enableInfiniteScroll: false, + viewportFraction: 0.96, + height: MediaQuery.of(context).size.height - bottomNavigationBarHeight - indicatorHeight, + onPageChanged: (index, reason) { + setState(() { + widget.updateIndex(index); + }); + }), + ), + ), + ), + if (cardAmount > 1) + Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Container( + height: indicatorHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: widget.cards.asMap().entries.map((entry) { + return GestureDetector( + onTap: () => widget.carouselController.animateToPage(entry.key), + child: Container( + width: 8.0, + height: 8.0, + margin: EdgeInsets.symmetric(horizontal: 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black) + .withOpacity(widget.cardIndex == entry.key ? 0.9 : 0.4)), + ), + ); + }).toList(), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/identification/card_detail_view/card_detail_view.dart b/frontend/lib/identification/card_detail_view/card_detail_view.dart index 7724e34c2..c7e67079e 100644 --- a/frontend/lib/identification/card_detail_view/card_detail_view.dart +++ b/frontend/lib/identification/card_detail_view/card_detail_view.dart @@ -7,9 +7,8 @@ import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; -import 'package:provider/provider.dart'; -import '../user_code_model.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; import 'verification_code_view.dart'; class CardDetailView extends StatefulWidget { @@ -17,14 +16,15 @@ class CardDetailView extends StatefulWidget { final VoidCallback startActivation; final VoidCallback startVerification; final VoidCallback startApplication; + final VoidCallback openRemoveCardDialog; - const CardDetailView({ - super.key, - required this.userCode, - required this.startActivation, - required this.startVerification, - required this.startApplication, - }); + const CardDetailView( + {super.key, + required this.userCode, + required this.startActivation, + required this.startVerification, + required this.startApplication, + required this.openRemoveCardDialog}); @override State createState() => _CardDetailViewState(); @@ -43,16 +43,15 @@ class _CardDetailViewState extends State { // - the card was activated on another device // - the card was revoked // - the card expired (on backend's system time) - _selfVerifyCard(); + _selfVerifyCard(widget.userCode); initiatedSelfVerification = true; } } - Future _selfVerifyCard() async { - final userCodeModel = Provider.of(context, listen: false); + Future _selfVerifyCard(DynamicUserCode userCode) async { final projectId = Configuration.of(context).projectId; final client = GraphQLProvider.of(context).value; - selfVerifyCard(userCodeModel, projectId, client); + selfVerifyCard(context, userCode, projectId, client); } @override @@ -87,7 +86,7 @@ class _CardDetailViewState extends State { final qrCodeAndStatus = QrCodeAndStatus( userCode: widget.userCode, onMoreActionsPressed: () => _onMoreActionsPressed(context), - onSelfVerifyPressed: _selfVerifyCard, + onSelfVerifyPressed: () => _selfVerifyCard(widget.userCode), ); return orientation == Orientation.landscape @@ -129,10 +128,10 @@ class _CardDetailViewState extends State { showDialog( context: context, builder: (context) => MoreActionsDialog( - startActivation: widget.startActivation, - startApplication: widget.startApplication, - startVerification: widget.startVerification, - ), + startActivation: widget.startActivation, + startApplication: widget.startApplication, + startVerification: widget.startVerification, + openRemoveCardDialog: widget.openRemoveCardDialog), ); } } @@ -186,41 +185,33 @@ class QrCodeAndStatus extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ...switch (status) { - CardStatus.expired => [ - _PaddedText( - 'Ihre Karte ist abgelaufen.\nUnter "Weitere Aktionen" können Sie einen Antrag auf Verlängerung stellen.') - ], + CardStatus.expired => [_PaddedText(t.identification.cardExpired)], CardStatus.notVerifiedLately => [ - _PaddedText( - 'Ihre Karte konnte nicht auf ihre Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Verbindung mit dem Internet besteht und prüfen Sie erneut.'), + _PaddedText(t.identification.checkFailed), Flexible( child: TextButton.icon( icon: const Icon(Icons.refresh), onPressed: onSelfVerifyPressed, - label: Text('Erneut prüfen'), + label: Text(t.common.tryAgain), ), ), ], CardStatus.timeOutOfSync => [ - _PaddedText( - 'Die Uhrzeit Ihres Geräts scheint nicht zu stimmen. Bitte synchronisieren Sie die Uhrzeit in den Systemeinstellungen.'), + _PaddedText(t.identification.timeIncorrect), Flexible( child: TextButton.icon( icon: const Icon(Icons.refresh), onPressed: onSelfVerifyPressed, - label: Text('Erneut prüfen'), + label: Text(t.common.tryAgain), )) ], - CardStatus.invalid => [ - _PaddedText( - 'Ihre Karte ist ungültig.\nSie wurde entweder widerrufen oder auf einem anderen Gerät aktiviert.') - ], + CardStatus.invalid => [_PaddedText(t.identification.cardInvalid)], CardStatus.valid => [ - _PaddedText('Mit diesem QR-Code können Sie sich bei Akzeptanzstellen ausweisen:'), + _PaddedText(t.identification.authenticationPossible), Flexible(child: VerificationCodeView(userCode: userCode)) ], CardStatus.notYetValid => [ - _PaddedText('Der Gültigkeitszeitraum Ihrer Karte hat noch nicht begonnen.'), + _PaddedText(t.identification.cardNotYetValid), ] }, Container( @@ -228,7 +219,7 @@ class QrCodeAndStatus extends StatelessWidget { child: TextButton( onPressed: onMoreActionsPressed, child: Text( - 'Weitere Aktionen', + t.common.moreActions, style: TextStyle(color: Theme.of(context).colorScheme.secondary), ), ), diff --git a/frontend/lib/identification/card_detail_view/more_actions_dialog.dart b/frontend/lib/identification/card_detail_view/more_actions_dialog.dart index 241671b2a..092067574 100644 --- a/frontend/lib/identification/card_detail_view/more_actions_dialog.dart +++ b/frontend/lib/identification/card_detail_view/more_actions_dialog.dart @@ -1,31 +1,41 @@ -import 'package:ehrenamtskarte/build_config/build_config.dart'; +import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; +import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:ehrenamtskarte/l10n/translations.g.dart'; class MoreActionsDialog extends StatelessWidget { final VoidCallback startActivation; final VoidCallback startVerification; final VoidCallback startApplication; + final VoidCallback openRemoveCardDialog; const MoreActionsDialog({ super.key, required this.startActivation, required this.startVerification, required this.startApplication, + required this.openRemoveCardDialog, }); @override Widget build(BuildContext context) { - final localization = buildConfig.localization.identification.moreActions; + final userCodeModel = Provider.of(context, listen: false); + final String cardsInUse = userCodeModel.userCodes.length.toString(); + final String maxCardAmount = buildConfig.maxCardAmount.toString(); + final bool cardLimitIsReached = hasReachedCardLimit(userCodeModel.userCodes); + return AlertDialog( contentPadding: const EdgeInsets.only(top: 12), - title: const Text('Weitere Aktionen'), + title: Text(t.common.moreActions), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( - title: Text(localization.applyForAnotherCardTitle), - subtitle: Text(localization.applyForAnotherCardDescription), + title: Text(t.identification.moreActionsApplyTitle), + subtitle: Text(t.identification.moreActionsApplyDescription), leading: const Icon(Icons.assignment, size: 36), onTap: () { Navigator.pop(context); @@ -33,27 +43,40 @@ class MoreActionsDialog extends StatelessWidget { }, ), ListTile( - title: Text(localization.activateAnotherCardTitle), - subtitle: Text(localization.activateAnotherCardDescription), - leading: const Icon(Icons.add_card, size: 36), + title: Text(t.identification.moreActionsVerifyTitle), + subtitle: Text(t.identification.moreActionsVerifyDescription), + leading: const Icon(Icons.verified, size: 36), + onTap: () { + Navigator.pop(context); + startVerification(); + }, + ), + ListTile( + enabled: !cardLimitIsReached, + title: Text('${t.identification.moreActionsActivateTitle} ($cardsInUse/$maxCardAmount)', + style: TextStyle(color: Theme.of(context).colorScheme.onBackground)), + subtitle: Text(cardLimitIsReached + ? t.identification.moreActionsActivateLimitDescription + : t.identification.moreActionsActivateDescription), + leading: Icon(Icons.add_card, size: 36), onTap: () { Navigator.pop(context); startActivation(); }, ), ListTile( - title: Text(localization.verifyTitle), - subtitle: Text(localization.verifyDescription), - leading: const Icon(Icons.verified, size: 36), + title: Text(t.identification.moreActionsRemoveTitle), + subtitle: Text(t.identification.moreActionsRemoveDescription), + leading: const Icon(Icons.delete, size: 36), onTap: () { Navigator.pop(context); - startVerification(); + openRemoveCardDialog(); }, ), ], ), ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'))], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], ); } } diff --git a/frontend/lib/identification/card_detail_view/self_verify_card.dart b/frontend/lib/identification/card_detail_view/self_verify_card.dart index 1aee9b3eb..0c1495dbc 100644 --- a/frontend/lib/identification/card_detail_view/self_verify_card.dart +++ b/frontend/lib/identification/card_detail_view/self_verify_card.dart @@ -1,22 +1,24 @@ +import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:flutter/widgets.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:provider/provider.dart'; import '../../proto/card.pb.dart'; import '../../util/date_utils.dart'; import '../otp_generator.dart'; -import '../user_code_model.dart'; import '../verification_workflow/query_server_verification.dart'; -Future selfVerifyCard(UserCodeModel userCodeModel, String projectId, GraphQLClient client) async { - final userCode = userCodeModel.userCode; - if (userCode == null) { +Future selfVerifyCard( + BuildContext context, DynamicUserCode? userCode, String projectId, GraphQLClient client) async { + final initialUserCode = userCode; + if (initialUserCode == null) { return; } - final otpCode = OTPGenerator(userCode.totpSecret).generateOTP(); + final otpCode = OTPGenerator(initialUserCode.totpSecret).generateOTP(); final DynamicVerificationCode qrCode = DynamicVerificationCode() - ..info = userCode.info - ..pepper = userCode.pepper + ..info = initialUserCode.info + ..pepper = initialUserCode.pepper ..otp = otpCode.code; debugPrint('Card Self-Verification: Requesting server'); @@ -25,18 +27,19 @@ Future selfVerifyCard(UserCodeModel userCodeModel, String projectId, Graph await queryDynamicServerVerification(client, projectId, qrCode); // If the user code has changed during the server request, we abort. - if (userCodeModel.userCode != userCode) { + if (userCode != initialUserCode) { debugPrint('Card Self-Verification: The user code has been changed during server request for the old user code.'); return; } - debugPrint("Card Self-Verification: Persisting response. Card is ${cardVerification.valid ? "valid." : "INVALID."}"); + debugPrint('Card Self-Verification: Persisting response. Card is ${cardVerification.valid ? 'valid.' : 'INVALID.'}'); - userCodeModel.setCode(DynamicUserCode() - ..info = userCode.info - ..ecSignature = userCode.ecSignature - ..pepper = userCode.pepper - ..totpSecret = userCode.totpSecret + final userCodeModel = Provider.of(context, listen: false); + userCodeModel.updateCode(DynamicUserCode() + ..info = initialUserCode.info + ..ecSignature = initialUserCode.ecSignature + ..pepper = initialUserCode.pepper + ..totpSecret = initialUserCode.totpSecret ..cardVerification = (CardVerification() ..cardValid = cardVerification.valid ..verificationTimeStamp = secondsSinceEpoch(DateTime.parse(cardVerification.verificationTimeStamp)) diff --git a/frontend/lib/identification/connection_failed_dialog.dart b/frontend/lib/identification/connection_failed_dialog.dart index a53410e26..5f0495c41 100644 --- a/frontend/lib/identification/connection_failed_dialog.dart +++ b/frontend/lib/identification/connection_failed_dialog.dart @@ -1,6 +1,8 @@ import 'package:ehrenamtskarte/identification/info_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class ConnectionFailedDialog extends StatelessWidget { final String reason; @@ -9,7 +11,7 @@ class ConnectionFailedDialog extends StatelessWidget { @override Widget build(BuildContext context) { return InfoDialog( - title: 'Keine Verbindung möglich', + title: t.common.connectionFailed, icon: Icons.signal_cellular_connected_no_internet_4_bar, iconColor: Theme.of(context).colorScheme.onBackground, child: Text(reason), diff --git a/frontend/lib/identification/id_card/card_content.dart b/frontend/lib/identification/id_card/card_content.dart index 836ce65cb..be8e79148 100644 --- a/frontend/lib/identification/id_card/card_content.dart +++ b/frontend/lib/identification/id_card/card_content.dart @@ -6,6 +6,8 @@ import 'package:ehrenamtskarte/util/color_utils.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + Color standardCardColor = getColorFromHex(buildConfig.cardBranding.colorStandard); Color premiumCardColor = getColorFromHex(buildConfig.cardBranding.colorPremium); Color textColor = getColorFromHex(buildConfig.cardBranding.bodyTextColor); @@ -46,11 +48,11 @@ class CardContent extends StatelessWidget { const CardContent( {super.key, required this.cardInfo, this.region, required this.isExpired, required this.isNotYetValid}); - String get _formattedExpirationDate { + String _getFormattedExpirationDate(BuildContext context) { final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null; return expirationDay != null ? DateFormat('dd.MM.yyyy').format(DateTime.fromMillisecondsSinceEpoch(0).add(Duration(days: expirationDay))) - : 'unbegrenzt'; + : t.identification.unlimited; } String? get _formattedBirthday { @@ -73,8 +75,10 @@ class CardContent extends StatelessWidget { : null; } - String _getCardValidityDate(String? startDate, String expirationDate) { - return startDate != null ? 'Gültig: $startDate bis $expirationDate' : 'Gültig bis: $expirationDate'; + String _getCardValidityDate(BuildContext context, String? startDate, String expirationDate) { + return startDate != null + ? t.identification.validFromUntil(startDate: startDate, expirationDate: expirationDate) + : t.identification.validUntil(expirationDate: expirationDate); } @override @@ -209,7 +213,7 @@ class CardContent extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 3.0), child: Text( - _getCardValidityDate(startDate, _formattedExpirationDate), + _getCardValidityDate(context, startDate, _getFormattedExpirationDate(context)), style: TextStyle( fontSize: 14 * scaleFactor, color: diff --git a/frontend/lib/identification/identification_page.dart b/frontend/lib/identification/identification_page.dart index 6dc0c0d66..e6629d2c7 100644 --- a/frontend/lib/identification/identification_page.dart +++ b/frontend/lib/identification/identification_page.dart @@ -1,21 +1,30 @@ +import 'package:carousel_slider/carousel_controller.dart'; import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/settings_model.dart'; import 'package:ehrenamtskarte/identification/activation_workflow/activation_code_scanner_page.dart'; +import 'package:ehrenamtskarte/identification/card_detail_view/card_carousel.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/card_detail_view.dart'; import 'package:ehrenamtskarte/identification/no_card_view.dart'; import 'package:ehrenamtskarte/identification/qr_code_scanner/qr_code_camera_permission_dialog.dart'; import 'package:ehrenamtskarte/identification/user_code_model.dart'; +import 'package:ehrenamtskarte/identification/verification_workflow/dialogs/remove_card_confirmation_dialog.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/verification_workflow.dart'; +import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/routing.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; -class IdentificationPage extends StatelessWidget { - final String title; +class IdentificationPage extends StatefulWidget { + const IdentificationPage({super.key}); + @override + IdentificationPageState createState() => IdentificationPageState(); +} - const IdentificationPage({super.key, required this.title}); +class IdentificationPageState extends State { + CarouselController carouselController = CarouselController(); + int cardIndex = 0; @override Widget build(BuildContext context) { @@ -27,18 +36,27 @@ class IdentificationPage extends StatelessWidget { return Container(); } - final userCode = userCodeModel.userCode; - if (userCode != null) { - return CardDetailView( - userCode: userCode, - startVerification: () => _showVerificationDialog(context, settings), - startActivation: () => _startActivation(context), - startApplication: _startApplication, - ); + if (userCodeModel.userCodes.isNotEmpty) { + final List carouselCards = []; + for (var code in userCodeModel.userCodes) { + carouselCards.add(CardDetailView( + userCode: code, + startVerification: () => _showVerificationDialog(context, settings, userCodeModel), + startActivation: () => _startActivation(context), + startApplication: _startApplication, + openRemoveCardDialog: () => _openRemoveCardDialog(context), + )); + } + + return CardCarousel( + cards: carouselCards, + cardIndex: cardIndex, + updateIndex: _updateCardIndex, + carouselController: carouselController); } return NoCardView( - startVerification: () => _showVerificationDialog(context, settings), + startVerification: () => _showVerificationDialog(context, settings, userCodeModel), startActivation: () => _startActivation(context), startApplication: _startApplication, ); @@ -50,17 +68,26 @@ class IdentificationPage extends StatelessWidget { await QrCodeCameraPermissionDialog.showPermissionDialog(context); } - Future _showVerificationDialog(BuildContext context, SettingsModel settings) async { + Future _showVerificationDialog( + BuildContext context, SettingsModel settings, UserCodeModel userCodeModel) async { if (await Permission.camera.request().isGranted) { - await VerificationWorkflow.startWorkflow(context, settings); + DynamicUserCode? userCode = userCodeModel.userCodes.isNotEmpty ? userCodeModel.userCodes[cardIndex] : null; + await VerificationWorkflow.startWorkflow(context, settings, userCode); return; } handleDeniedCameraPermission(context); } + Future _updateCardIndex(int index) async { + setState(() { + cardIndex = index; + }); + } + Future _startActivation(BuildContext context) async { if (await Permission.camera.request().isGranted) { - Navigator.push(context, AppRoute(builder: (context) => const ActivationCodeScannerPage())); + Navigator.push(context, + AppRoute(builder: (context) => ActivationCodeScannerPage(moveToLastCard: _moveCarouselToLastPosition))); return; } handleDeniedCameraPermission(context); @@ -72,4 +99,19 @@ class IdentificationPage extends StatelessWidget { mode: LaunchMode.externalApplication, ); } + + Future _openRemoveCardDialog(BuildContext context) async { + final userCodeModel = Provider.of(context, listen: false); + await RemoveCardConfirmationDialog.show( + context: context, userCode: userCodeModel.userCodes[cardIndex], carouselController: carouselController); + } + + void _moveCarouselToLastPosition() { + final userCodeModel = Provider.of(context, listen: false); + final int cardAmount = userCodeModel.userCodes.length; + // the carousel controller causes an error if you try to move if there is only one item + if (cardAmount > 1) { + carouselController.jumpToPage(cardAmount); + } + } } diff --git a/frontend/lib/identification/info_dialog.dart b/frontend/lib/identification/info_dialog.dart index d4f9d98af..aceccc19c 100644 --- a/frontend/lib/identification/info_dialog.dart +++ b/frontend/lib/identification/info_dialog.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class InfoDialog extends StatelessWidget { final Widget child; final String title; @@ -23,7 +25,7 @@ class InfoDialog extends StatelessWidget { title: Text(title, style: theme.textTheme.headlineSmall), ), content: child, - actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK'))], + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(t.common.ok))], ); } } diff --git a/frontend/lib/identification/no_card_view.dart b/frontend/lib/identification/no_card_view.dart index 056f26b7c..b5fcc46e9 100644 --- a/frontend/lib/identification/no_card_view.dart +++ b/frontend/lib/identification/no_card_view.dart @@ -1,4 +1,4 @@ -import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; import 'package:flutter/material.dart'; class NoCardView extends StatelessWidget { @@ -15,7 +15,6 @@ class NoCardView extends StatelessWidget { @override Widget build(BuildContext context) { - final localization = buildConfig.localization.identification.noCardView; return LayoutBuilder( builder: (BuildContext context, BoxConstraints viewportConstraints) => SingleChildScrollView( child: ConstrainedBox( @@ -27,20 +26,20 @@ class NoCardView extends StatelessWidget { children: [ _TapableCardWithArea( onTap: startApplication, - title: localization.applyTitle, - description: localization.applyDescription, + title: t.identification.applyTitle, + description: t.identification.applyDescription, icon: Icons.assignment, ), _TapableCardWithArea( onTap: startActivation, - title: localization.activateTitle, - description: localization.activateDescription, + title: t.identification.activateTitle, + description: t.identification.activateDescription, icon: Icons.add_card, ), _TapableCardWithArea( onTap: startVerification, - title: localization.verifyTitle, - description: localization.verifyDescription, + title: t.identification.verifyTitle, + description: t.identification.verifyDescription, icon: Icons.verified, ), ].wrapWithSpacers(height: 24), diff --git a/frontend/lib/identification/qr_code_scanner/qr_code_camera_permission_dialog.dart b/frontend/lib/identification/qr_code_scanner/qr_code_camera_permission_dialog.dart index 94db41714..1d4911a3f 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_code_camera_permission_dialog.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_code_camera_permission_dialog.dart @@ -1,31 +1,31 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class QrCodeCameraPermissionDialog extends StatelessWidget { const QrCodeCameraPermissionDialog(); @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Zugriff auf Kamera erforderlich', style: TextStyle(fontSize: 18)), + title: Text(t.identification.cameraAccessRequired, style: TextStyle(fontSize: 18)), content: SingleChildScrollView( child: ListBody( - children: const [ - Text( - 'Um einen QR-Code einzuscannen, benötigt die App Zugriff auf die Kamera.\nIn den Einstellungen können Sie der App den Zugriff auf die Kamera erlauben.', - ), + children: [ + Text(t.identification.cameraAccessRequiredSettings), ], ), ), actions: [ TextButton( - child: const Text('Abbrechen'), + child: Text(t.common.cancel), onPressed: () { Navigator.of(context).pop(); }, ), TextButton( - child: const Text('Einstellungen öffnen'), + child: Text(t.common.openSettings), onPressed: () { openAppSettings(); }, diff --git a/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart b/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart index dfd893137..bf0f5684a 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart @@ -6,6 +6,8 @@ import 'package:ehrenamtskarte/identification/qr_code_scanner/qr_overlay_shape.d import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + typedef OnCodeScannedCallback = Future Function(Uint8List code); class QrCodeScanner extends StatefulWidget { @@ -75,7 +77,7 @@ class _QRViewState extends State { children: [ Container( margin: const EdgeInsets.all(8), - child: const Text('Halten Sie die Kamera auf den QR Code.'), + child: Text(t.identification.scanQRCode), ), QrCodeScannerControls(controller: controller) ], diff --git a/frontend/lib/identification/qr_code_scanner/qr_code_scanner_controls.dart b/frontend/lib/identification/qr_code_scanner/qr_code_scanner_controls.dart index ade18c3f1..0dd49d2e3 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_code_scanner_controls.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_code_scanner_controls.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class QrCodeScannerControls extends StatelessWidget { final MobileScannerController controller; @@ -19,7 +21,7 @@ class QrCodeScannerControls extends StatelessWidget { child: ValueListenableBuilder( valueListenable: controller.torchState, builder: (ctx, state, child) => Text( - state == TorchState.on ? 'Blitz aus' : 'Blitz an', + state == TorchState.on ? t.identification.flashOff : t.identification.flashOn, style: const TextStyle(fontSize: 16), ), ), @@ -32,7 +34,7 @@ class QrCodeScannerControls extends StatelessWidget { child: ValueListenableBuilder( valueListenable: controller.cameraFacingState, builder: (ctx, state, child) => Text( - state == CameraFacing.back ? 'Frontkamera' : 'Standard-Kamera', + state == CameraFacing.back ? t.identification.selfieCamera : t.identification.standardCamera, style: const TextStyle(fontSize: 16), ), ), diff --git a/frontend/lib/identification/qr_code_scanner/qr_overlay_shape.dart b/frontend/lib/identification/qr_code_scanner/qr_overlay_shape.dart index 48c44dfda..e0a85596a 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_overlay_shape.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_overlay_shape.dart @@ -19,7 +19,7 @@ class QrScannerOverlayShape extends ShapeBorder { cutOutHeight = cutOutHeight ?? cutOutSize ?? 250 { assert( borderLength <= min(this.cutOutWidth, this.cutOutHeight) / 2 + borderWidth * 2, - "Border can't be larger than ${min(this.cutOutWidth, this.cutOutHeight) / 2 + borderWidth * 2}", + 'Border can\'t be larger than ${min(this.cutOutWidth, this.cutOutHeight) / 2 + borderWidth * 2}', ); // ignore: prefer_asserts_in_initializer_lists assert( diff --git a/frontend/lib/identification/qr_code_scanner/qr_parsing_error_dialog.dart b/frontend/lib/identification/qr_code_scanner/qr_parsing_error_dialog.dart index d3e9fdc1a..ce1b3ab78 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_parsing_error_dialog.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_parsing_error_dialog.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class QrParsingErrorDialog extends StatelessWidget { final String message; @@ -8,7 +10,7 @@ class QrParsingErrorDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Fehler beim Lesen des Codes'), + title: Text(t.identification.scanningFailed), content: SingleChildScrollView( child: ListBody( children: [ @@ -18,7 +20,7 @@ class QrParsingErrorDialog extends StatelessWidget { ), actions: [ TextButton( - child: const Text('Ok'), + child: Text(t.common.ok), onPressed: () { Navigator.of(context).pop(); }, diff --git a/frontend/lib/identification/qr_content_parser.dart b/frontend/lib/identification/qr_content_parser.dart index 49c48b9bb..720804dfe 100644 --- a/frontend/lib/identification/qr_content_parser.dart +++ b/frontend/lib/identification/qr_content_parser.dart @@ -22,8 +22,7 @@ extension QRParsing on Uint8List { qrcode = QrCode.fromBuffer(this); } on Exception catch (e, stackTrace) { throw VerificationParseException( - internalMessage: 'Failed to parse QrCode from encoded data. ' - 'Message: ${e.toString()}', + internalMessage: 'Failed to parse QrCode from encoded data. Message: ${e.toString()}', cause: e, stackTrace: stackTrace, ); diff --git a/frontend/lib/identification/user_code_model.dart b/frontend/lib/identification/user_code_model.dart index 66c2577ce..8b7a7bbdc 100644 --- a/frontend/lib/identification/user_code_model.dart +++ b/frontend/lib/identification/user_code_model.dart @@ -1,15 +1,16 @@ import 'dart:developer'; +import 'package:ehrenamtskarte/build_config/build_config.dart'; import 'package:ehrenamtskarte/identification/user_code_store.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:flutter/foundation.dart'; class UserCodeModel extends ChangeNotifier { - DynamicUserCode? _userCode; + List _userCodes = []; bool _isInitialized = false; - DynamicUserCode? get userCode { - return _userCode; + List get userCodes { + return _userCodes; } bool get isInitialized { @@ -21,7 +22,8 @@ class UserCodeModel extends ChangeNotifier { return; } try { - _userCode = await const UserCodeStore().load(); + await UserCodeStore().importLegacyCard(); + _userCodes = await const UserCodeStore().load(); } on Exception catch (e) { log('Failed to initialize activation code from secure storage.', error: e); } finally { @@ -30,15 +32,49 @@ class UserCodeModel extends ChangeNotifier { } } - void setCode(DynamicUserCode code) { - const UserCodeStore().store(code); - _userCode = code; + void insertCode(DynamicUserCode code) { + List userCodes = _userCodes; + if (isAlreadyInList(userCodes, code.info)) return; + userCodes.add(code); + const UserCodeStore().store(userCodes); + _userCodes = userCodes; notifyListeners(); } - void removeCode() { + void updateCode(DynamicUserCode code) { + List userCodes = _userCodes; + if (isAlreadyInList(userCodes, code.info)) { + userCodes = updateUserCode(userCodes, code); + const UserCodeStore().store(userCodes); + _userCodes = userCodes; + notifyListeners(); + } + } + + void removeCode(DynamicUserCode code) { + List userCodes = _userCodes; + userCodes.remove(code); + const UserCodeStore().store(userCodes); + _userCodes = userCodes; + notifyListeners(); + } + + void removeCodes() { const UserCodeStore().remove(); - _userCode = null; + _userCodes = []; notifyListeners(); } + + List updateUserCode(List userCodes, DynamicUserCode userCode) { + userCodes[userCodes.indexWhere((code) => code.info == userCode.info)] = userCode; + return userCodes; + } +} + +bool isAlreadyInList(List userCodes, CardInfo info) { + return userCodes.map((userCode) => userCode.info).contains(info); +} + +bool hasReachedCardLimit(List userCodes) { + return userCodes.length >= buildConfig.maxCardAmount; } diff --git a/frontend/lib/identification/user_code_store.dart b/frontend/lib/identification/user_code_store.dart index 78cc3c11b..47379c593 100644 --- a/frontend/lib/identification/user_code_store.dart +++ b/frontend/lib/identification/user_code_store.dart @@ -1,32 +1,44 @@ import 'dart:convert'; +import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -const userCodeBase64Key = 'userCodeBase64'; - class UserCodeStore { const UserCodeStore(); + static const _userCodesBase64Key = 'userCodesBase64'; + // legacy key for single card static const _userCodeBase64Key = 'userCodeBase64'; - - Future store(DynamicUserCode userCode) async { + static const _storageDelimiter = ','; + Future store(List userCodes) async { const storage = FlutterSecureStorage(); - await storage.write( - key: _userCodeBase64Key, - value: const Base64Encoder().convert(userCode.writeToBuffer()), - ); + Iterable userCodeString = userCodes.map((code) => const Base64Encoder().convert(code.writeToBuffer())); + await storage.write(key: _userCodesBase64Key, value: userCodeString.join(_storageDelimiter)); } Future remove() async { const storage = FlutterSecureStorage(); - await storage.delete(key: _userCodeBase64Key); + await storage.delete(key: _userCodesBase64Key); } - Future load() async { + Future> load() async { + const storage = FlutterSecureStorage(); + final String? userCodesBase64 = await storage.read(key: _userCodesBase64Key); + if (userCodesBase64 == null) return []; + return userCodesBase64 + .split(_storageDelimiter) + .map((code) => DynamicUserCode.fromBuffer(const Base64Decoder().convert(code))) + .toList(); + } + + // legacy import of existing card to keep them in storage after update + Future importLegacyCard() async { const storage = FlutterSecureStorage(); final String? userCodeBase64 = await storage.read(key: _userCodeBase64Key); - if (userCodeBase64 == null) return null; - return DynamicUserCode.fromBuffer(const Base64Decoder().convert(userCodeBase64)); + if (userCodeBase64 == null) return; + DynamicUserCode importedLegacyCard = DynamicUserCode.fromBuffer(const Base64Decoder().convert(userCodeBase64)); + UserCodeModel().insertCode(importedLegacyCard); + await storage.delete(key: _userCodeBase64Key); } } diff --git a/frontend/lib/identification/verification_workflow/dialogs/negative_verification_result_dialog.dart b/frontend/lib/identification/verification_workflow/dialogs/negative_verification_result_dialog.dart index 72e234bfd..ae0b0cb29 100644 --- a/frontend/lib/identification/verification_workflow/dialogs/negative_verification_result_dialog.dart +++ b/frontend/lib/identification/verification_workflow/dialogs/negative_verification_result_dialog.dart @@ -1,6 +1,8 @@ import 'package:ehrenamtskarte/identification/info_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class NegativeVerificationResultDialog extends StatelessWidget { final String reason; @@ -9,7 +11,7 @@ class NegativeVerificationResultDialog extends StatelessWidget { @override Widget build(BuildContext context) { return InfoDialog( - title: 'Nicht verifiziert', + title: t.identification.notVerified, icon: Icons.error, iconColor: Colors.red, child: Text(reason), diff --git a/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart b/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart index 01b709dc0..d7daae30c 100644 --- a/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart +++ b/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart @@ -1,4 +1,3 @@ -import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/configuration.dart'; import 'package:ehrenamtskarte/graphql/graphql_api.dart'; import 'package:ehrenamtskarte/identification/id_card/id_card.dart'; @@ -7,6 +6,8 @@ import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class PositiveVerificationResultDialog extends StatefulWidget { final CardInfo cardInfo; final bool isStaticVerificationCode; @@ -39,7 +40,6 @@ class PositiveVerificationResultDialogState extends State show( + {required BuildContext context, + required DynamicUserCode userCode, + required CarouselController carouselController}) => + showDialog( + context: context, + builder: (_) => RemoveCardConfirmationDialog(userCode: userCode, carouselController: carouselController), + ); + + @override + RemoveCardConfirmationDialogState createState() => RemoveCardConfirmationDialogState(); +} + +class RemoveCardConfirmationDialogState extends State { + @override + Widget build(BuildContext context) { + final projectId = Configuration.of(context).projectId; + final regionsQuery = GetRegionsByIdQuery( + variables: GetRegionsByIdArguments( + project: projectId, + ids: [widget.userCode.info.extensions.extensionRegion.regionId], + ), + ); + + return Query( + options: QueryOptions(document: regionsQuery.document, variables: regionsQuery.getVariablesMap()), + builder: (result, {refetch, fetchMore}) { + final data = result.data; + final theme = Theme.of(context); + final region = result.isConcrete && data != null ? regionsQuery.parse(data).regionsByIdInProject[0] : null; + return AlertDialog( + titlePadding: EdgeInsets.all(4), + contentPadding: EdgeInsets.only(left: 20, right: 20), + actionsPadding: EdgeInsets.only(left: 20, right: 20, top: 10, bottom: 10), + title: ListTile( + leading: Icon(Icons.warning, color: theme.colorScheme.primaryContainer, size: 30), + title: Text(t.identification.removeTitle, style: TextStyle(fontSize: 18)), + ), + content: SingleChildScrollView( + child: ListBody( + children: [ + Padding( + padding: EdgeInsets.only(bottom: 20), + child: Text(t.identification.removeDescription, style: TextStyle(fontSize: 14))), + IdCard( + cardInfo: widget.userCode.info, + region: region != null ? Region(region.prefix, region.name) : null, + // We trust the backend to have checked for expiration. + isExpired: false, + isNotYetValid: false, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Abbrechen'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: const Text('Löschen'), + onPressed: () { + final provider = Provider.of(context, listen: false); + // ensures that the store will be reset to empty list + if (provider.userCodes.length == 1) { + provider.removeCodes(); + } else { + provider.removeCode(widget.userCode); + widget.carouselController.previousPage(duration: Duration(milliseconds: 500), curve: Curves.linear); + } + Navigator.of(context).pop(true); + }, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart b/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart index b4842cc76..19e83c7ab 100644 --- a/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart +++ b/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart @@ -1,34 +1,32 @@ -import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/settings_model.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class VerificationInfoDialog extends StatelessWidget { const VerificationInfoDialog({super.key}); @override Widget build(BuildContext context) { final settings = Provider.of(context); - final localization = buildConfig.localization.identification.verificationCodeScanner; return AlertDialog( - title: Text(localization.infoDialogTitle), + title: Text(t.identification.verifyInfoTitle), content: SingleChildScrollView( child: ListBody( - children: const [ + children: [ _EnumeratedListItem( index: 0, - child: Text( - 'Scannen Sie den QR-Code, der auf dem "Ausweisen"-Tab Ihres Gegenübers angezeigt wird.', - ), + child: Text(t.identification.scanCode), ), - _EnumeratedListItem(index: 1, child: Text('Der QR-Code wird durch eine Server-Anfrage geprüft.')), + _EnumeratedListItem(index: 1, child: Text(t.identification.checkingCode)), _EnumeratedListItem( index: 2, - child: Text('Gleichen Sie die angezeigten Daten mit einem amtlichen Lichtbildausweis ab.'), + child: Text(t.identification.compareWithID), ), SizedBox(height: 12), Text( - 'Eine Internetverbindung wird benötigt.', + t.identification.internetRequired, style: TextStyle(fontWeight: FontWeight.bold), ), ], @@ -36,14 +34,14 @@ class VerificationInfoDialog extends StatelessWidget { ), actions: [ TextButton( - child: const Text('Nicht mehr anzeigen'), + child: Text(t.identification.stopShowing), onPressed: () async { await settings.setHideVerificationInfo(enabled: true); _onDone(context); }, ), TextButton( - child: const Text('Weiter'), + child: Text(t.common.next), onPressed: () => _onDone(context), ) ], diff --git a/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart b/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart index 7c7dd91e1..47c251578 100644 --- a/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart +++ b/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart @@ -33,7 +33,7 @@ Future verifyDynamicVerificationCode( _assertConsistentDynamicVerificationCode(code); final (outOfSync: outOfSync, result: result) = await queryDynamicServerVerification(client, projectId, code); if (outOfSync) { - debugPrint("Verification: This device's time is out of sync with the server." + debugPrint('Verification: This device\'s time is out of sync with the server.' 'Ignoring, as only the time of the device that generates the QR code is relevant for the verification process.'); } diff --git a/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart b/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart index cb5884a43..3332d7532 100644 --- a/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart +++ b/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart @@ -1,6 +1,5 @@ import 'dart:typed_data'; -import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/configuration/configuration.dart'; import 'package:ehrenamtskarte/configuration/settings_model.dart'; import 'package:ehrenamtskarte/identification/connection_failed_dialog.dart'; @@ -8,7 +7,6 @@ import 'package:ehrenamtskarte/identification/otp_generator.dart'; import 'package:ehrenamtskarte/identification/qr_code_scanner/qr_code_processor.dart'; import 'package:ehrenamtskarte/identification/qr_code_scanner/qr_code_scanner_page.dart'; import 'package:ehrenamtskarte/identification/qr_content_parser.dart'; -import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/dialogs/negative_verification_result_dialog.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/dialogs/verification_info_dialog.dart'; @@ -20,17 +18,21 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class VerificationQrScannerPage extends StatelessWidget { - const VerificationQrScannerPage({super.key}); + final DynamicUserCode? userCode; + const VerificationQrScannerPage({super.key, this.userCode}); @override Widget build(BuildContext context) { final config = Configuration.of(context); final settings = Provider.of(context); + final currentUserCode = userCode; return Column( children: [ CustomAppBar( - title: buildConfig.localization.identification.verificationCodeScanner.title, + title: t.identification.verifyTitle, actions: [ IconButton( icon: const Icon(Icons.help), @@ -47,16 +49,14 @@ class VerificationQrScannerPage extends StatelessWidget { onCodeScanned: (code) => _handleQrCode(context, code), ), ), - if (config.showDevSettings) + if (config.showDevSettings && currentUserCode != null) TextButton( onPressed: () async { - final provider = Provider.of(context, listen: false); - final userCode = provider.userCode!; - final otp = OTPGenerator(userCode.totpSecret).generateOTP().code; + final otp = OTPGenerator(currentUserCode.totpSecret).generateOTP().code; final verificationQrCode = QrCode() ..dynamicVerificationCode = (DynamicVerificationCode() - ..info = userCode.info - ..pepper = userCode.pepper + ..info = currentUserCode.info + ..pepper = currentUserCode.pepper ..otp = otp); final verificationCode = verificationQrCode.writeToBuffer(); _handleQrCode(context, verificationCode); @@ -75,7 +75,7 @@ class VerificationQrScannerPage extends StatelessWidget { if (cardInfo == null) { await _onError( context, - 'Der eingescannte Code konnte vom Server nicht verifiziert werden!', + t.identification.codeVerificationFailed, ); } else { await _onSuccess(context, cardInfo, qrcode.hasStaticVerificationCode()); @@ -83,41 +83,36 @@ class VerificationQrScannerPage extends StatelessWidget { } on ServerVerificationException catch (e) { await _onConnectionError( context, - 'Der eingescannte Code konnte nicht verifiziert ' - 'werden, da die Kommunikation mit dem Server fehlschlug. ' - 'Bitte prüfen Sie Ihre Internetverbindung.', + t.identification.codeVerificationFailedConnection, e, ); } on QrCodeFieldMissingException catch (e) { await _onError( context, - 'Der eingescannte Code ist nicht gültig, ' - 'da erforderliche Daten fehlen.', + t.identification.codeInvalidMissing(missing: e.missingFieldName), e, ); } on CardExpiredException catch (e) { - final dateFormat = DateFormat('dd.MM.yyyy'); + final expirationDate = DateFormat('dd.MM.yyyy').format(e.expiry); await _onError( context, - 'Der eingescannte Code ist bereits am ${dateFormat.format(e.expiry)} abgelaufen.', + t.identification.codeExpired(expirationDate: expirationDate), e, ); } on QrCodeParseException catch (e) { await _onError( context, - 'Der Inhalt des eingescannten Codes kann nicht verstanden ' - 'werden. Vermutlich handelt es sich um einen QR-Code, der nicht für ' - 'diese App generiert wurde.', + t.identification.codeInvalid, e, ); } on Exception catch (e) { await _onError( context, - 'Beim Einlesen des QR-Codes ist ein unbekannter Fehler aufgetreten.', + t.identification.codeUnknownError, e, ); } finally { - // close current "Karte verifizieren" view + // close current 'Karte verifizieren' view await Navigator.of(context).maybePop(); } } diff --git a/frontend/lib/identification/verification_workflow/verification_workflow.dart b/frontend/lib/identification/verification_workflow/verification_workflow.dart index cf60b136d..2889ca37b 100644 --- a/frontend/lib/identification/verification_workflow/verification_workflow.dart +++ b/frontend/lib/identification/verification_workflow/verification_workflow.dart @@ -1,16 +1,17 @@ import 'package:ehrenamtskarte/configuration/settings_model.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/dialogs/verification_info_dialog.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/verification_qr_scanner_page.dart'; +import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/routing.dart'; import 'package:flutter/material.dart'; class VerificationWorkflow { VerificationWorkflow._(); // hide the constructor - static Future startWorkflow(BuildContext context, SettingsModel settings) => - VerificationWorkflow._().showInfoAndQrScanner(context, settings); + static Future startWorkflow(BuildContext context, SettingsModel settings, DynamicUserCode? userCode) => + VerificationWorkflow._().showInfoAndQrScanner(context, settings, userCode); - Future showInfoAndQrScanner(BuildContext rootContext, SettingsModel settings) async { + Future showInfoAndQrScanner(BuildContext rootContext, SettingsModel settings, DynamicUserCode? userCode) async { if (settings.hideVerificationInfo != true) { // show info dialog and cancel if it is not accepted if (await VerificationInfoDialog.show(rootContext) != true) return; @@ -21,7 +22,7 @@ class VerificationWorkflow { rootContext, AppRoute( builder: (context) { - return const VerificationQrScannerPage(); + return VerificationQrScannerPage(userCode: userCode); }, ), ); diff --git a/frontend/lib/intro_slides/intro_screen.dart b/frontend/lib/intro_slides/intro_screen.dart index 1e76aa391..cb23cae0c 100644 --- a/frontend/lib/intro_slides/intro_screen.dart +++ b/frontend/lib/intro_slides/intro_screen.dart @@ -4,6 +4,8 @@ import 'package:ehrenamtskarte/intro_slides/location_request_button.dart'; import 'package:flutter/material.dart'; import 'package:intro_slider/intro_slider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + typedef OnFinishedCallback = void Function(); class IntroScreen extends StatelessWidget { @@ -21,9 +23,9 @@ class IntroScreen extends StatelessWidget { final theme = Theme.of(context); return IntroSlider( onDonePress: () => onDonePress(context), - renderDoneBtn: const Text('Fertig'), - renderNextBtn: const Text('Weiter'), - renderPrevBtn: const Text('Zurück'), + renderDoneBtn: Text(t.common.done), + renderNextBtn: Text(t.common.next), + renderPrevBtn: Text(t.common.previous), doneButtonStyle: Theme.of(context).textButtonTheme.style, indicatorConfig: IndicatorConfig( colorActiveIndicator: theme.colorScheme.primary, @@ -32,9 +34,9 @@ class IntroScreen extends StatelessWidget { isShowSkipBtn: false, listContentConfig: [ ContentConfig( - title: buildConfig.introSlide1.title, - description: buildConfig.introSlide1.description, - pathImage: buildConfig.introSlide1.imagePath, + title: t.intro.welcomeTitle, + description: t.intro.welcomeDescription, + pathImage: buildConfig.introSlidesImages[0], backgroundColor: theme.brightness == Brightness.light ? const Color(0xffECECEC) : theme.colorScheme.background, maxLineTitle: 3, @@ -42,9 +44,9 @@ class IntroScreen extends StatelessWidget { styleDescription: theme.textTheme.bodyLarge?.apply(fontSizeFactor: 1.2), ), ContentConfig( - title: buildConfig.introSlide2.title, - description: buildConfig.introSlide2.description, - pathImage: buildConfig.introSlide2.imagePath, + title: t.intro.applyTitle, + description: t.intro.applyDescription, + pathImage: buildConfig.introSlidesImages[1], backgroundColor: theme.brightness == Brightness.light ? const Color(0xffECECEC) : theme.colorScheme.background, maxLineTitle: 3, @@ -52,9 +54,9 @@ class IntroScreen extends StatelessWidget { styleDescription: theme.textTheme.bodyLarge?.apply(fontSizeFactor: 1.2), ), ContentConfig( - title: buildConfig.introSlide3.title, - description: buildConfig.introSlide3.description, - pathImage: buildConfig.introSlide3.imagePath, + title: t.intro.usageTitle, + description: t.intro.usageDescription, + pathImage: buildConfig.introSlidesImages[2], backgroundColor: theme.brightness == Brightness.light ? const Color(0xffECECEC) : theme.colorScheme.background, maxLineTitle: 3, @@ -62,17 +64,17 @@ class IntroScreen extends StatelessWidget { styleDescription: theme.textTheme.bodyLarge?.apply(fontSizeFactor: 1.2), ), ContentConfig( - title: buildConfig.introSlide4.title, + title: t.intro.locationTitle, backgroundColor: theme.brightness == Brightness.light ? const Color(0xffECECEC) : theme.colorScheme.background, maxLineTitle: 3, styleTitle: theme.textTheme.headlineSmall, - pathImage: buildConfig.introSlide4.imagePath, + pathImage: buildConfig.introSlidesImages[3], widgetDescription: Center( child: Column( children: [ Text( - buildConfig.introSlide4.description, + t.intro.locationDescription, style: theme.textTheme.bodyLarge?.apply(fontSizeFactor: 1.2), textAlign: TextAlign.center, maxLines: 100, diff --git a/frontend/lib/intro_slides/location_request_button.dart b/frontend/lib/intro_slides/location_request_button.dart index d6758765a..cdb9849de 100644 --- a/frontend/lib/intro_slides/location_request_button.dart +++ b/frontend/lib/intro_slides/location_request_button.dart @@ -3,6 +3,8 @@ import 'package:ehrenamtskarte/location/determine_position.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class LocationRequestButton extends StatefulWidget { const LocationRequestButton({super.key}); @@ -49,9 +51,9 @@ class _LocationRequestButtonState extends State { final settings = Provider.of(context); final status = _locationPermissionStatus; if (status == null) { - return const ElevatedButton( + return ElevatedButton( onPressed: null, - child: Text('Prüfe Einstellungen...'), + child: Text(t.location.checkSettings), ); } switch (status) { @@ -59,18 +61,18 @@ class _LocationRequestButtonState extends State { case LocationStatus.notSupported: return ElevatedButton( onPressed: () => _onLocationButtonClicked(settings), - child: const Text('Ich möchte meinen Standort freigeben.'), + child: Text(t.location.grantLocation), ); case LocationStatus.whileInUse: case LocationStatus.always: - return const ElevatedButton( + return ElevatedButton( onPressed: null, - child: Text('Standort ist freigegeben.'), + child: Text(t.location.locationGranted), ); case LocationStatus.deniedForever: - return const ElevatedButton( + return ElevatedButton( onPressed: null, - child: Text('Standortfreigabe ist deaktiviert.'), + child: Text(t.location.locationAccessDeactivated), ); } } diff --git a/frontend/lib/location/determine_position.dart b/frontend/lib/location/determine_position.dart index 1ffe1190e..42c9d726d 100644 --- a/frontend/lib/location/determine_position.dart +++ b/frontend/lib/location/determine_position.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/mapbox_gl.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + enum LocationStatus { /// This is the initial state on both Android and iOS, but on Android the /// user can still choose to deny permissions, meaning the App can still @@ -101,8 +103,6 @@ Future determinePosition( Future checkAndRequestLocationPermission( BuildContext context, { bool requestIfNotGranted = true, - String rationale = - 'Erlauben Sie der App Ihren Standort zu benutzen, um Akzeptanzstellen in Ihrer Umgebung anzuzeigen.', Future Function()? onDisableFeature, Future Function()? onEnableFeature, }) async { @@ -144,13 +144,14 @@ Future checkAndRequestLocationPermission( // returned true. According to Android guidelines // your App should show an explanatory UI now. - final result = await showDialog(context: context, builder: (context) => RationaleDialog(rationale: rationale)); + final result = await showDialog( + context: context, + builder: (context) => RationaleDialog(rationale: t.location.activateLocationAccessRationale)); if (result == true) { return checkAndRequestLocationPermission( context, requestIfNotGranted: requestIfNotGranted, - rationale: rationale, ); } diff --git a/frontend/lib/location/dialogs.dart b/frontend/lib/location/dialogs.dart index 1abb34165..08749c416 100644 --- a/frontend/lib/location/dialogs.dart +++ b/frontend/lib/location/dialogs.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class LocationServiceDialog extends StatelessWidget { const LocationServiceDialog({super.key}); @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Standortermittlung aktivieren'), - content: const Text('Aktivieren Sie die Standortermittlung in den Einstellungen.'), + title: Text(t.location.activateLocationAccess), + content: Text(t.location.activateLocationAccessSettings), actions: [ - TextButton(child: const Text('Abbrechen'), onPressed: () => Navigator.of(context).pop(false)), - TextButton(child: const Text('Einstellungen öffnen'), onPressed: () => Navigator.of(context).pop(true)) + TextButton(child: Text(t.common.cancel), onPressed: () => Navigator.of(context).pop(false)), + TextButton(child: Text(t.common.openSettings), onPressed: () => Navigator.of(context).pop(true)) ], ); } @@ -24,15 +26,15 @@ class RationaleDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Standortberechtigung'), + title: Text(t.location.locationPermission), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [Text(_rationale), const Text('Soll nocheinmal nach der Berechtigung gefragt werden?')], + children: [Text(_rationale), Text(t.location.askPermissionsAgain)], ), actions: [ - TextButton(child: const Text('Berechtigung erteilen'), onPressed: () => Navigator.of(context).pop(true)), - TextButton(child: const Text('Abbrechen'), onPressed: () => Navigator.of(context).pop(false)) + TextButton(child: Text(t.location.grantPermission), onPressed: () => Navigator.of(context).pop(true)), + TextButton(child: Text(t.common.cancel), onPressed: () => Navigator.of(context).pop(false)) ], ); } diff --git a/frontend/lib/location/location_ffi.dart b/frontend/lib/location/location_ffi.dart index 622fbbe83..c2bff91c0 100644 --- a/frontend/lib/location/location_ffi.dart +++ b/frontend/lib/location/location_ffi.dart @@ -9,6 +9,6 @@ Future isNonGoogleLocationServiceEnabled() async { final bool result = await platform.invokeMethod('isLocationServiceEnabled') as bool; return result; } on PlatformException catch (e) { - throw "Failed to get state of location services: '${e.message}'."; + throw 'Failed to get state of location services: "${e.message}".'; } } diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 806d76177..311e136dc 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,16 +1,49 @@ +import 'dart:io'; + import 'package:ehrenamtskarte/app.dart'; +import 'package:ehrenamtskarte/build_config/build_config.dart'; import 'package:ehrenamtskarte/configuration/definitions.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; import 'package:ehrenamtskarte/sentry.dart'; import 'package:ehrenamtskarte/settings_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:slang/builder/model/enums.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Only use device locale if set as available in build config, otherwise fallback to de + final locale = Platform.localeName.split('_')[0]; + if (buildConfig.appLocales.contains(locale)) { + LocaleSettings.useDeviceLocale(); + } else if (buildConfig.appLocales.contains('en')) { + LocaleSettings.setLocale(AppLocale.en); + } else { + LocaleSettings.setLocale(AppLocale.de); + } + + // Use override locales for whitelabels (e.g. nuernberg) + // ignore: unnecessary_null_comparison + if (buildConfig.localeOverridePath != null) { + void override(AppLocale locale) async { + final localeOverridePath = '${buildConfig.localeOverridePath}/override_${locale.languageCode}.json'; + String overrideLocales = await rootBundle.loadString(localeOverridePath); + LocaleSettings.overrideTranslations(locale: locale, fileType: FileType.json, content: overrideLocales); + } + + AppLocale.values.forEach(override); + } + debugPrint('Environment: $appEnvironment'); + + void run() { + return runApp(TranslationProvider(child: SettingsProvider(child: const App()))); + } + if (isProduction()) { - runAppWithSentry(); + runAppWithSentry(run); } else { - runApp(SettingsProvider(child: const App())); + run(); } } diff --git a/frontend/lib/map/location_button.dart b/frontend/lib/map/location_button.dart index 31efec19b..51ff6f614 100644 --- a/frontend/lib/map/location_button.dart +++ b/frontend/lib/map/location_button.dart @@ -4,6 +4,8 @@ import 'package:ehrenamtskarte/widgets/small_button_spinner.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class LocationButton extends StatefulWidget { final Future Function(RequestedPosition) bringCameraToUser; @@ -52,9 +54,9 @@ class _LocationButtonState extends State { messengerState.showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, - content: const Text('Die Standortfreigabe ist deaktiviert.'), + content: Text(t.location.locationAccessDeactivated), action: SnackBarAction( - label: 'Einstellungen', + label: t.common.settings, onPressed: () async { await openSettingsToGrantPermissions(context); }, diff --git a/frontend/lib/map/map/attribution_dialog.dart b/frontend/lib/map/map/attribution_dialog.dart index ca26e1921..d4881b139 100644 --- a/frontend/lib/map/map/attribution_dialog.dart +++ b/frontend/lib/map/map/attribution_dialog.dart @@ -2,6 +2,8 @@ import 'package:ehrenamtskarte/map/map/attribution_dialog_item.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class AttributionDialog extends StatelessWidget { const AttributionDialog({super.key}); @@ -9,12 +11,12 @@ class AttributionDialog extends StatelessWidget { Widget build(BuildContext context) { final color = Theme.of(context).colorScheme.primary; return SimpleDialog( - title: const Text('Kartendaten'), + title: Text(t.map.mapData), children: [ AttributionDialogItem( icon: Icons.copyright, color: color, - text: 'OpenStreetMap Mitwirkende', + text: t.map.osmContributors, onPressed: () { launchUrlString('https://www.openstreetmap.org/copyright', mode: LaunchMode.externalApplication); }, diff --git a/frontend/lib/map/map/map.dart b/frontend/lib/map/map/map.dart index fdf9e6c61..addf92c3d 100644 --- a/frontend/lib/map/map/map.dart +++ b/frontend/lib/map/map/map.dart @@ -12,6 +12,8 @@ import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/mapbox_gl.dart'; import 'package:tuple/tuple.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + typedef OnFeatureClickCallback = void Function(dynamic feature); typedef OnNoFeatureClickCallback = void Function(); typedef OnMapCreatedCallback = void Function(MapController controller); @@ -96,7 +98,7 @@ class _MapContainerState extends State implements MapController { color: mapboxColor, iconSize: 20, icon: const Icon(Icons.info_outline), - tooltip: 'Zeige Infos über das Urheberrecht der Kartendaten', + tooltip: t.map.showMapCopyright, onPressed: () { showDialog( context: context, diff --git a/frontend/lib/map/preview/accepting_store_preview_card.dart b/frontend/lib/map/preview/accepting_store_preview_card.dart index 7c0f8f646..3e4f9537d 100644 --- a/frontend/lib/map/preview/accepting_store_preview_card.dart +++ b/frontend/lib/map/preview/accepting_store_preview_card.dart @@ -4,6 +4,8 @@ import 'package:ehrenamtskarte/store_widgets/accepting_store_summary.dart'; import 'package:ehrenamtskarte/widgets/error_message.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class AcceptingStorePreviewError extends StatelessWidget { final void Function()? refetch; @@ -14,10 +16,9 @@ class AcceptingStorePreviewError extends StatelessWidget { return InkWell( onTap: refetch, child: Container( - height: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: const ErrorMessage('Fehler beim Laden der Infos.'), - ), + height: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ErrorMessage(t.store.loadingDataFailed)), ); } } diff --git a/frontend/lib/search/filter_bar.dart b/frontend/lib/search/filter_bar.dart index faf93b41c..8f9881e8c 100644 --- a/frontend/lib/search/filter_bar.dart +++ b/frontend/lib/search/filter_bar.dart @@ -4,6 +4,8 @@ import 'package:ehrenamtskarte/category_assets.dart'; import 'package:ehrenamtskarte/search/filter_bar_button.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class FilterBar extends StatelessWidget { final Function(CategoryAsset, bool) onCategoryPress; @@ -11,7 +13,7 @@ class FilterBar extends StatelessWidget { @override Widget build(BuildContext context) { - final sortedCategories = [...categoryAssets]; + final sortedCategories = [...categoryAssets(context)]; sortedCategories.removeWhere((category) => category.id == 9); sortedCategories.sort((a, b) => a.shortName.length.compareTo(b.shortName.length)); final filteredCategories = sortedCategories.where((element) => buildConfig.categories.contains(element.id)); @@ -23,7 +25,8 @@ class FilterBar extends StatelessWidget { padding: const EdgeInsets.all(8), child: Row( children: [ - Text('Nach Kategorien filtern'.toUpperCase(), maxLines: 1, style: const TextStyle(color: Colors.grey)), + Text(t.search.filterByCategories.toUpperCase(), + maxLines: 1, style: const TextStyle(color: Colors.grey)), const Expanded(child: Padding(padding: EdgeInsets.only(left: 8), child: Divider(thickness: 0.7))) ], ), diff --git a/frontend/lib/search/location_button.dart b/frontend/lib/search/location_button.dart index 2ce536e7d..d1d8e69ea 100644 --- a/frontend/lib/search/location_button.dart +++ b/frontend/lib/search/location_button.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class LocationButton extends StatefulWidget { final void Function(Position position) setCoordinates; @@ -55,7 +57,7 @@ class _LocationButtonState extends State { ), ), label: Text( - 'In meiner Nähe suchen', + t.search.findCloseBy, style: TextStyle(color: Theme.of(context).hintColor), ), ), diff --git a/frontend/lib/search/results_loader.dart b/frontend/lib/search/results_loader.dart index b3f0b22d5..bd955a24c 100644 --- a/frontend/lib/search/results_loader.dart +++ b/frontend/lib/search/results_loader.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class ResultsLoader extends StatefulWidget { final CoordinatesInput? coordinates; final String? searchText; @@ -146,10 +148,10 @@ class ResultsLoaderState extends State { mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.warning, size: 60, color: Colors.orange), - const Text('Bitte Internetverbindung prüfen.'), + Text(t.common.checkConnection), OutlinedButton( onPressed: _pagingController.retryLastFailedRequest, - child: const Text('Erneut versuchen'), + child: Text(t.common.tryAgain), ) ], ), @@ -160,7 +162,7 @@ class ResultsLoaderState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.search_off, size: 60, color: Theme.of(context).disabledColor), - const Text('Auf diese Suche trifft keine Akzeptanzstelle zu.'), + Text(t.search.noAcceptingStoresFound), ], ), ); diff --git a/frontend/lib/search/search_page.dart b/frontend/lib/search/search_page.dart index f436cc593..eeb2adf5b 100644 --- a/frontend/lib/search/search_page.dart +++ b/frontend/lib/search/search_page.dart @@ -6,6 +6,8 @@ import 'package:ehrenamtskarte/search/results_loader.dart'; import 'package:ehrenamtskarte/widgets/app_bars.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -37,7 +39,7 @@ class _SearchPageState extends State { child: Row( children: [ Text( - 'Suchresultate'.toUpperCase(), + t.search.searchResults.toUpperCase(), style: const TextStyle(color: Colors.grey), ), const Expanded(child: Padding(padding: EdgeInsets.only(left: 8), child: Divider())) diff --git a/frontend/lib/sentry.dart b/frontend/lib/sentry.dart index e82315635..859c8faff 100644 --- a/frontend/lib/sentry.dart +++ b/frontend/lib/sentry.dart @@ -1,13 +1,10 @@ -import 'package:ehrenamtskarte/settings_provider.dart'; import 'package:flutter/cupertino.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'app.dart'; - -Future runAppWithSentry() async { +Future runAppWithSentry(void Function() runApp) async { await SentryFlutter.init((options) { options.dsn = 'https://ceb1e25ecc334e26b1469a0bc325b7c9@sentry.tuerantuer.org/4'; - }, appRunner: () => runApp(SettingsProvider(child: const App()))); + }, appRunner: runApp); } Future reportError(dynamic exception, dynamic stackTrace) async { diff --git a/frontend/lib/store_widgets/accepting_store_summary.dart b/frontend/lib/store_widgets/accepting_store_summary.dart index c1a68da94..c401bfaa4 100644 --- a/frontend/lib/store_widgets/accepting_store_summary.dart +++ b/frontend/lib/store_widgets/accepting_store_summary.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class AcceptingStoreSummary extends StatelessWidget { final AcceptingStoreSummaryModel store; final CoordinatesInput? coordinates; @@ -36,8 +38,9 @@ class AcceptingStoreSummary extends StatelessWidget { @override Widget build(BuildContext context) { - final itemCategoryAsset = store.categoryId < categoryAssets.length ? categoryAssets[store.categoryId] : null; - final categoryName = itemCategoryAsset?.name ?? 'Unbekannte Kategorie'; + final categories = categoryAssets(context); + final itemCategoryAsset = store.categoryId < categories.length ? categories[store.categoryId] : null; + final categoryName = itemCategoryAsset?.name ?? t.store.unknownCategory; final categoryColor = itemCategoryAsset?.color; final useWideDepiction = MediaQuery.of(context).size.width > 400; @@ -152,14 +155,14 @@ class StoreTextOverview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - store.name ?? 'Akzeptanzstelle', + store.name ?? t.store.acceptingStore, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 4), Text( - store.description ?? 'Keine Beschreibung verfügbar', + store.description ?? t.store.noDescriptionAvailable, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, @@ -180,9 +183,9 @@ class DistanceText extends StatelessWidget { if (d < 1) { return '${(d * 100).round() * 10} m'; } else if (d < 10) { - return "${NumberFormat("##.0", "de").format(d)} km"; + return '${NumberFormat('##.0', 'de').format(d)} km'; } else { - return "${NumberFormat("###,###", "de").format(d)} km"; + return '${NumberFormat('###,###', 'de').format(d)} km'; } } diff --git a/frontend/lib/store_widgets/detail/detail_app_bar.dart b/frontend/lib/store_widgets/detail/detail_app_bar.dart index c97af0c40..f739a9937 100644 --- a/frontend/lib/store_widgets/detail/detail_app_bar.dart +++ b/frontend/lib/store_widgets/detail/detail_app_bar.dart @@ -6,6 +6,8 @@ import 'package:ehrenamtskarte/widgets/app_bars.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + const double bottomSize = 100; class DetailAppBarHeaderImage extends StatelessWidget { @@ -16,9 +18,10 @@ class DetailAppBarHeaderImage extends StatelessWidget { @override Widget build(BuildContext context) { final currentCategoryId = categoryId; + final categories = categoryAssets(context); - if (currentCategoryId != null && currentCategoryId <= categoryAssets.length) { - final currentDetailIcon = categoryAssets[currentCategoryId].detailIcon; + if (currentCategoryId != null && currentCategoryId <= categories.length) { + final currentDetailIcon = categories[currentCategoryId].detailIcon; if (currentDetailIcon != null) { return SvgPicture.asset( currentDetailIcon, @@ -84,10 +87,10 @@ class DetailAppBar extends StatelessWidget { @override Widget build(BuildContext context) { final categoryId = matchingStore.store.category.id; + final category = categoryAssets(context)[categoryId]; - final accentColor = getDarkenedColorForCategory(categoryId); - final categoryName = matchingStore.store.category.name; - final title = matchingStore.store.name ?? 'Akzeptanzstelle'; + final accentColor = getDarkenedColorForCategory(context, categoryId); + final title = matchingStore.store.name ?? t.store.acceptingStore; final backgroundColor = accentColor ?? Theme.of(context).colorScheme.primary; final textColor = getReadableOnColor(backgroundColor); @@ -105,7 +108,7 @@ class DetailAppBar extends StatelessWidget { child: DetailAppBarBottom( title: title, categoryId: categoryId, - categoryName: categoryName, + categoryName: category.name, accentColor: accentColor, textColorGrey: textColorGrey, textColor: textColor, diff --git a/frontend/lib/store_widgets/detail/detail_content.dart b/frontend/lib/store_widgets/detail/detail_content.dart index 872d810a6..ec723fb4b 100644 --- a/frontend/lib/store_widgets/detail/detail_content.dart +++ b/frontend/lib/store_widgets/detail/detail_content.dart @@ -11,6 +11,8 @@ import 'package:maplibre_gl/mapbox_gl.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class DetailContent extends StatelessWidget { final AcceptingStoreById$Query$PhysicalStore acceptingStore; final bool hideShowOnMapButton; @@ -30,8 +32,8 @@ class DetailContent extends StatelessWidget { final address = acceptingStore.address; final street = address.street; final location = '${address.postalCode} ${address.location}'; - final addressString = "${street != null ? "$street\n" : ""}$location"; - final mapQueryString = "${street != null ? "$street, " : ""}$location"; + final addressString = '${street != null ? '$street\n' : ''}$location'; + final mapQueryString = '${street != null ? '$street, ' : ''}$location'; final contact = acceptingStore.store.contact; final currentAccentColor = accentColor; @@ -59,7 +61,7 @@ class DetailContent extends StatelessWidget { ContactInfoRow( Icons.location_on, addressString, - 'Adresse', + t.store.address, onTap: () => _launchMap(mapQueryString), iconColor: readableOnAccentColor, iconFillColor: accentColor, @@ -68,7 +70,7 @@ class DetailContent extends StatelessWidget { ContactInfoRow( Icons.language, prepareWebsiteUrlForDisplay(website), - 'Website', + t.store.website, onTap: () => launchUrlString(prepareWebsiteUrlForLaunch(website), mode: LaunchMode.externalApplication), iconColor: readableOnAccentColor, @@ -78,7 +80,7 @@ class DetailContent extends StatelessWidget { ContactInfoRow( Icons.phone, telephone, - 'Telefon', + t.store.phone, onTap: () => launchUrlString('tel:${sanitizePhoneNumber(telephone)}', mode: LaunchMode.externalApplication), iconColor: readableOnAccentColor, @@ -88,7 +90,7 @@ class DetailContent extends StatelessWidget { ContactInfoRow( Icons.alternate_email, email, - 'E-Mail', + t.store.email, onTap: () => launchUrlString('mailto:${email.trim()}', mode: LaunchMode.externalApplication), iconColor: readableOnAccentColor, iconFillColor: accentColor, @@ -105,7 +107,7 @@ class DetailContent extends StatelessWidget { alignment: MainAxisAlignment.center, children: [ OutlinedButton( - child: const Text('Auf Karte zeigen'), + child: Text(t.store.showOnMap), onPressed: () => _showOnMap(context), ), ], diff --git a/frontend/lib/store_widgets/detail/detail_page.dart b/frontend/lib/store_widgets/detail/detail_page.dart index f54825cec..af48af720 100644 --- a/frontend/lib/store_widgets/detail/detail_page.dart +++ b/frontend/lib/store_widgets/detail/detail_page.dart @@ -9,6 +9,8 @@ import 'package:ehrenamtskarte/widgets/top_loading_spinner.dart'; import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class DetailPage extends StatelessWidget { final int _acceptingStoreId; final bool hideShowOnMapButton; @@ -27,18 +29,18 @@ class DetailPage extends StatelessWidget { final data = result.data; if (result.hasException && exception != null) { - return DetailErrorMessage(message: 'Fehler beim Laden der Daten', refetch: refetch); + return DetailErrorMessage(message: t.store.loadingDataFailed, refetch: refetch); } else if (result.isNotLoading && data != null) { final matchingStores = byIdQuery.parse(data).physicalStoresByIdInProject; if (matchingStores.length != 1) { - return DetailErrorMessage(message: 'Fehler beim Laden der Daten.', refetch: refetch); + return DetailErrorMessage(message: t.store.loadingDataFailed, refetch: refetch); } final matchingStore = matchingStores.first; if (matchingStore == null) { - return const DetailErrorMessage(message: 'Akzeptanzstelle nicht gefunden.'); + return DetailErrorMessage(message: t.store.acceptingStoreNotFound); } final categoryId = matchingStore.store.category.id; - final accentColor = getDarkenedColorForCategory(categoryId); + final accentColor = getDarkenedColorForCategory(context, categoryId); return Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/frontend/lib/themes.dart b/frontend/lib/themes.dart index 71cebd3eb..7c925b8d3 100644 --- a/frontend/lib/themes.dart +++ b/frontend/lib/themes.dart @@ -16,7 +16,7 @@ ThemeData get lightTheme { ), textTheme: defaultTypography.copyWith( headlineMedium: defaultTypography.headlineMedium?.apply(color: Colors.black87), - headlineSmall: defaultTypography.headlineMedium?.apply(color: Colors.black87), + headlineSmall: defaultTypography.headlineSmall?.apply(color: Colors.black87), titleLarge: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), bodyLarge: const TextStyle(fontSize: 15.0, fontWeight: FontWeight.normal), bodyMedium: const TextStyle(fontSize: 15.0, color: Color(0xFF505050)), @@ -59,7 +59,7 @@ ThemeData get darkTheme { ), textTheme: defaultTypography.copyWith( headlineMedium: defaultTypography.headlineMedium?.apply(color: Colors.white), - headlineSmall: defaultTypography.headlineMedium?.apply(color: Colors.white), + headlineSmall: defaultTypography.headlineSmall?.apply(color: Colors.white), titleLarge: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), bodyLarge: const TextStyle(fontSize: 15.0, fontWeight: FontWeight.normal), bodyMedium: const TextStyle(fontSize: 15.0, color: Color(0xFFC6C4C4)), diff --git a/frontend/lib/util/color_utils.dart b/frontend/lib/util/color_utils.dart index 9c4a9bc2c..dfd40578c 100644 --- a/frontend/lib/util/color_utils.dart +++ b/frontend/lib/util/color_utils.dart @@ -10,8 +10,8 @@ Color getReadableOnColorSecondary(Color backgroundColor) { return backgroundColor.computeLuminance() > 0.5 ? Colors.black54 : Colors.white54; } -Color? getDarkenedColorForCategory(int categoryId) { - final categoryColor = categoryAssets[categoryId].color; +Color? getDarkenedColorForCategory(BuildContext context, int categoryId) { + final categoryColor = categoryAssets(context)[categoryId].color; Color? categoryColorDark; if (categoryColor != null) { categoryColorDark = TinyColor.fromColor(categoryColor).darken().color; diff --git a/frontend/lib/util/json_canonicalizer.dart b/frontend/lib/util/json_canonicalizer.dart index 2bbe24117..353e9bf4c 100644 --- a/frontend/lib/util/json_canonicalizer.dart +++ b/frontend/lib/util/json_canonicalizer.dart @@ -49,7 +49,7 @@ class JsonCanonicalizer { } stringBuffer.write('}'); } else { - throw ArgumentError("Could not serialize value '$jsonObject'!"); + throw ArgumentError('Could not serialize value "$jsonObject"!'); } } } diff --git a/frontend/lib/widgets/app_bars.dart b/frontend/lib/widgets/app_bars.dart index ad4825f17..ac2cb10c1 100644 --- a/frontend/lib/widgets/app_bars.dart +++ b/frontend/lib/widgets/app_bars.dart @@ -5,6 +5,8 @@ library navigation_bars; import 'package:ehrenamtskarte/debouncer.dart'; import 'package:flutter/material.dart'; +import 'package:ehrenamtskarte/l10n/translations.g.dart'; + class CustomAppBar extends StatelessWidget { final String title; final List? actions; @@ -91,7 +93,7 @@ class SearchSliverAppBarState extends State { controller: textEditingController, focusNode: focusNode, decoration: InputDecoration.collapsed( - hintText: 'Tippen, um zu suchen...', + hintText: t.search.searchHint, hintStyle: TextStyle(color: foregroundColor?.withOpacity(0.8)), ), cursorColor: foregroundColor, diff --git a/frontend/pubs/df_build_config/lib/builder.dart b/frontend/pubs/df_build_config/lib/builder.dart index b7bbb632b..98292f879 100644 --- a/frontend/pubs/df_build_config/lib/builder.dart +++ b/frontend/pubs/df_build_config/lib/builder.dart @@ -38,6 +38,8 @@ void pairToField(String k, dynamic v, StringBuffer root, StringBuffer output) { } else if (v is String) { final escaped = v.replaceAll('"', '\\"').replaceAll("\n", "\\n"); output.write(' String get $k => "$escaped";\n'); + } else if (v is String?) { + output.write(' String? get $k => null;\n'); } else if (v is bool) { output.write(' bool get $k => $v;\n'); } else if (v is double) { @@ -54,6 +56,8 @@ void pairToField(String k, dynamic v, StringBuffer root, StringBuffer output) { if (element is int) { output.write(' List get $k => $v;\n'); + } else if (element is String) { + output.write(' List get $k => [\'${v.join('\', \'')}\'];\n'); } else { throw "invalid list ${v.runtimeType}"; } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 2510f9b47..80ba28d44 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.6.0" + carousel_slider: + dependency: "direct main" + description: + name: carousel_slider + sha256: "9c695cc963bf1d04a47bd6021f68befce8970bcd61d24938e1fb0918cf5d9c42" + url: "https://pub.dev" + source: hosted + version: "4.2.1" characters: dependency: transitive description: @@ -209,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + csv: + dependency: transitive + description: + name: csv + sha256: "142bdf2b24f4a49e35a0fc4398f21d861c4c0f9015e8054dcacd0bb8e23ee27d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" cupertino_icons: dependency: "direct main" description: @@ -611,6 +627,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json2yaml: + dependency: transitive + description: + name: json2yaml + sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51 + url: "https://pub.dev" + source: hosted + version: "3.0.1" json_annotation: dependency: "direct dev" description: @@ -1124,6 +1148,30 @@ packages: description: flutter source: sdk version: "0.0.99" + slang: + dependency: "direct main" + description: + name: slang + sha256: "829ae38374a328ac8d97d5835e8b4e9bbed1993f66ca85771c5ccec9c87ac397" + url: "https://pub.dev" + source: hosted + version: "3.25.0" + slang_build_runner: + dependency: "direct dev" + description: + name: slang_build_runner + sha256: f5003a3aa8a6a72de59c8ad29c072da9ab5d1b81c599798c0f651c4e5c7e25e5 + url: "https://pub.dev" + source: hosted + version: "3.25.0" + slang_flutter: + dependency: "direct main" + description: + name: slang_flutter + sha256: cb5e1611744cca620cc03f93a54eca6918e25ae7d600cd940ef2d556e2be4c64 + url: "https://pub.dev" + source: hosted + version: "3.25.0" sliver_tools: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 1a9930b11..145fcc894 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.2.0+113 +version: 3.2.1+114 environment: sdk: 3.0.2 @@ -53,6 +53,9 @@ dependencies: shared_preferences: ^2.0.18 tinycolor2: ^3.0.1 sentry_flutter: ^7.9.0 + carousel_slider: ^4.2.1 + slang: ^3.25.0 + slang_flutter: ^3.25.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -66,6 +69,7 @@ dependencies: dev_dependencies: build_runner: ^2.3.3 + slang_build_runner: ^3.25.0 df_protobuf: path: ./pubs/df_protobuf df_build_config: @@ -105,6 +109,7 @@ flutter: - assets/nuernberg/body-logo.png - assets/nuernberg/background.png - assets/nuernberg/intro_slides/ + - assets/nuernberg/l10n/ # An image asset can refer to one or more resolution-specific 'variants', see # https://flutter.dev/assets-and-images/#resolution-aware.