diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/administration/.eslintrc.js b/administration/.eslintrc.js index a125c5045..fd34cb105 100644 --- a/administration/.eslintrc.js +++ b/administration/.eslintrc.js @@ -105,7 +105,7 @@ module.exports = { }, overrides: [ { - files: ['*.test.{ts,tsx}', '**/__mocks__/*.{ts,tsx}', 'jest.setup.ts', 'jest.config.ts'], + files: ['*.test.{ts,tsx}', '**/__mocks__/*.{ts,tsx}', '**/testing/*.{ts,tsx}', 'jest.setup.ts', 'jest.config.ts'], rules: { 'global-require': 'off', 'no-console': 'off', diff --git a/administration/src/bp-modules/cards/AddCardsForm.tsx b/administration/src/bp-modules/cards/AddCardsForm.tsx index 810e6a1b3..a7c6cd98d 100644 --- a/administration/src/bp-modules/cards/AddCardsForm.tsx +++ b/administration/src/bp-modules/cards/AddCardsForm.tsx @@ -6,9 +6,9 @@ import styled from 'styled-components' import { Card, initializeCard, initializeCardFromCSV } from '../../cards/Card' import { Region } from '../../generated/graphql' import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import { getCsvHeaders } from '../../project-configs/helper' import AddCardForm from './AddCardForm' import CardFormButton from './CardFormButton' -import { getHeaders } from './ImportCardsController' const FormsWrapper = styled(FlipMove)` flex-wrap: wrap; @@ -55,7 +55,7 @@ const AddCardsForm = ({ useEffect(() => { if (cards.length === 0) { - const headers = getHeaders(projectConfig) + const headers = getCsvHeaders(projectConfig) const values = headers.map(header => searchParams.get(header)) setCards([initializeCardFromCSV(projectConfig.card, values, headers, region, true)]) diff --git a/administration/src/bp-modules/cards/ImportCardsController.tsx b/administration/src/bp-modules/cards/ImportCardsController.tsx index ecf986378..99f551da0 100644 --- a/administration/src/bp-modules/cards/ImportCardsController.tsx +++ b/administration/src/bp-modules/cards/ImportCardsController.tsx @@ -1,35 +1,20 @@ import { NonIdealState, Spinner } from '@blueprintjs/core' -import React, { ReactElement, useCallback, useContext, useMemo } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import React, { ReactElement, useContext } from 'react' +import { useNavigate } from 'react-router-dom' -import { FREINET_PARAM } from '../../Router' import { WhoAmIContext } from '../../WhoAmIProvider' -import { Card, initializeCardFromCSV } from '../../cards/Card' import { Region } from '../../generated/graphql' -import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' -import { ProjectConfig } from '../../project-configs/getProjectConfig' import useBlockNavigation from '../../util/useBlockNavigation' import GenerationFinished from './CardsCreatedMessage' import CreateCardsButtonBar from './CreateCardsButtonBar' -import { convertFreinetImport } from './ImportCardsFromFreinetController' import ImportCardsInput from './ImportCardsInput' import CardImportTable from './ImportCardsTable' import useCardGenerator, { CardActivationState } from './hooks/useCardGenerator' -export const getHeaders = (projectConfig: ProjectConfig): string[] => [ - projectConfig.card.nameColumnName, - projectConfig.card.expiryColumnName, - ...(projectConfig.card.extensionColumnNames.filter(Boolean) as string[]), -] - const InnerImportCardsController = ({ region }: { region: Region }): ReactElement => { const { state, setState, generateCardsPdf, generateCardsCsv, setCards, cards } = useCardGenerator(region) - const projectConfig = useContext(ProjectConfigContext) - const headers = useMemo(() => getHeaders(projectConfig), [projectConfig]) const navigate = useNavigate() - const isFreinetFormat = new URLSearchParams(useLocation().search).get(FREINET_PARAM) === 'true' - useBlockNavigation({ when: cards.length > 0, message: 'Falls Sie fortfahren, werden alle Eingaben verworfen.', @@ -43,20 +28,10 @@ const InnerImportCardsController = ({ region }: { region: Region }): ReactElemen } } - // TODO headers or csvHeader? - const lineToCard = useCallback( - (line: string[], csvHeader: string[]): Card => { - if (isFreinetFormat) { - convertFreinetImport(line, csvHeader, projectConfig) - } - return initializeCardFromCSV(projectConfig.card, line, csvHeader, region) - }, - [projectConfig, region, isFreinetFormat] - ) - if (state === CardActivationState.loading) { return } + if (state === CardActivationState.finished) { return ( {cards.length === 0 ? ( - + ) : ( - + )} ({})) -const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) - describe('ImportCardsInput', () => { + beforeEach(jest.clearAllMocks) + const region: Region = { id: 0, name: 'augsburg', @@ -30,12 +26,10 @@ describe('ImportCardsInput', () => { activatedForCardConfirmationMail: true, } - const renderAndSubmitCardsInput = async ( - projectConfig: ProjectConfig, - csv: string, - lineToCard: () => Card, - setCards: () => void - ) => { + const toaster = jest.spyOn(OverlayToaster.prototype, 'show') + const setCards = jest.fn() + + const renderAndSubmitCardsInput = async (projectConfig: ProjectConfig, csv: string, setCards: () => void) => { const fileReaderMock = { // eslint-disable-next-line func-names readAsText: jest.fn(function (this: FileReader, _: Blob) { @@ -44,17 +38,14 @@ describe('ImportCardsInput', () => { } as unknown as FileReader jest.spyOn(global, 'FileReader').mockReturnValue(fileReaderMock) const file = new File([csv], `${projectConfig.name}.csv`, { type: 'text/csv' }) - - localStorage.setItem(LOCAL_STORAGE_PROJECT_KEY, projectConfig.projectId) - - const { getByTestId } = render( - , - { wrapper } + setProjectConfigOverride(projectConfig.projectId) + + const { getByTestId } = renderWithRouter( + + + + + ) const fileInput = getByTestId('file-upload') as HTMLInputElement @@ -65,33 +56,90 @@ describe('ImportCardsInput', () => { await waitFor(() => expect(fileReaderMock.readAsText).toHaveBeenCalledTimes(1)) } - it.each([ - { - projectConfig: bayernConfig, - csv: ` + it('should correctly import CSV Card for bayern', async () => { + const projectConfig = bayernConfig + const csv = ` Name,Ablaufdatum,Kartentyp Thea Test,03.04.2024,Standard Tilo Traber,,Gold -`, - }, - { - projectConfig: nuernbergConfig, - csv: ` -Name,Ablaufdatum,Geburtsdatum,Passnummer +` + await renderAndSubmitCardsInput(projectConfig, csv, setCards) + + expect(toaster).not.toHaveBeenCalled() + expect(setCards).toHaveBeenCalledTimes(1) + expect(setCards).toHaveBeenCalledWith([ + { + expirationDate: PlainDate.fromCustomFormat('03.04.2024'), + extensions: { bavariaCardType: 'Standard', regionId: 0 }, + fullName: 'Thea Test', + id: expect.any(Number), + }, + { expirationDate: null, extensions: { regionId: 0 }, fullName: 'Tilo Traber', id: expect.any(Number) }, + ]) + }) + + it('should correctly import CSV Card for nuernberg', async () => { + const projectConfig = nuernbergConfig + const csv = ` +Name,Ablaufdatum,Geburtsdatum,Pass-ID Thea Test,03.04.2024,10.10.2000,12345678 Tilo Traber,03.04.2025,12.01.1984,98765432 -`, - }, - ])(`Correctly import CSV Card for project $projectConfig.name`, async ({ projectConfig, csv }) => { - const toaster = jest.spyOn(OverlayToaster.prototype, 'show') - const lineToCard = jest.fn(() => initializeCard(projectConfig.card, region)) - const setCards = jest.fn() +` + + await renderAndSubmitCardsInput(projectConfig, csv, setCards) + + expect(toaster).not.toHaveBeenCalled() + expect(setCards).toHaveBeenCalledTimes(1) + expect(setCards).toHaveBeenCalledWith([ + { + expirationDate: PlainDate.fromCustomFormat('03.04.2024'), + extensions: { birthday: PlainDate.fromCustomFormat('10.10.2000'), regionId: 0, nuernbergPassId: 12345678 }, + fullName: 'Thea Test', + id: expect.any(Number), + }, + { + expirationDate: PlainDate.fromCustomFormat('03.04.2025'), + extensions: { birthday: PlainDate.fromCustomFormat('12.01.1984'), regionId: 0, nuernbergPassId: 98765432 }, + fullName: 'Tilo Traber', + id: expect.any(Number), + }, + ]) + }) + + it('should correctly import CSV Card for koblenz', async () => { + const projectConfig = koblenzConfig + const csv = ` +Name,Ablaufdatum,Geburtsdatum,Referenznummer +Thea Test,03.04.2024,10.10.2000,123k +Tilo Traber,03.04.2025,12.01.1984,98765432 +` - await renderAndSubmitCardsInput(projectConfig, csv, lineToCard, setCards) + await renderAndSubmitCardsInput(projectConfig, csv, setCards) expect(toaster).not.toHaveBeenCalled() expect(setCards).toHaveBeenCalledTimes(1) - expect(lineToCard).toHaveBeenCalledTimes(2) + expect(setCards).toHaveBeenCalledWith([ + { + expirationDate: PlainDate.fromCustomFormat('03.04.2024'), + extensions: { + birthday: PlainDate.fromCustomFormat('10.10.2000'), + regionId: 0, + koblenzReferenceNumber: '123k', + }, + fullName: 'Thea Test', + id: expect.any(Number), + }, + { + expirationDate: PlainDate.fromCustomFormat('03.04.2025'), + extensions: { + birthday: PlainDate.fromCustomFormat('12.01.1984'), + regionId: 0, + koblenzReferenceNumber: '98765432', + }, + fullName: 'Tilo Traber', + id: expect.any(Number), + }, + ]) }) it.each([ @@ -118,15 +166,13 @@ ${'Thea Test,03.04.2024,12345678\n'.repeat(ENTRY_LIMIT + 1)} `, error: `Die Datei hat mehr als ${ENTRY_LIMIT} Einträge.`, }, - ])(`Import CSV Card should fail with error '$error'`, async ({ csv, error }) => { + ])(`import CSV Card should fail with error '$error'`, async ({ csv, error }) => { const toaster = jest.spyOn(OverlayToaster.prototype, 'show') - const lineToCard = jest.fn(() => initializeCard(bayernConfig.card, region)) const setCards = jest.fn() - await renderAndSubmitCardsInput(bayernConfig, csv, lineToCard, setCards) + await renderAndSubmitCardsInput(bayernConfig, csv, setCards) expect(toaster).toHaveBeenCalledWith({ intent: 'danger', message: error }) expect(setCards).not.toHaveBeenCalled() - expect(lineToCard).not.toHaveBeenCalled() }) }) diff --git a/administration/src/bp-modules/cards/ImportCardsInput.tsx b/administration/src/bp-modules/cards/ImportCardsInput.tsx index ad1ede9b5..ba803f4a5 100644 --- a/administration/src/bp-modules/cards/ImportCardsInput.tsx +++ b/administration/src/bp-modules/cards/ImportCardsInput.tsx @@ -1,10 +1,16 @@ import { NonIdealState } from '@blueprintjs/core' -import React, { ChangeEventHandler, ReactElement, useCallback, useRef, useState } from 'react' +import React, { ChangeEvent, ReactElement, useCallback, useContext, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' import styled from 'styled-components' -import { Card } from '../../cards/Card' +import { FREINET_PARAM } from '../../Router' +import { Card, initializeCardFromCSV } from '../../cards/Card' +import { Region } from '../../generated/graphql' +import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import { getCsvHeaders } from '../../project-configs/helper' import { useAppToaster } from '../AppToaster' import FileInputStateIcon from '../FileInputStateIcon' +import convertFreinetImport from '../util/convertFreinetImport' import ImportCardsRequirementsText from './ImportCardsRequirementsText' const CardImportInputContainer = styled.div` @@ -28,13 +34,14 @@ const FILE_SIZE_LIMIT_BYTES = FILE_SIZE_LIMIT_MEGA_BYTES * 1000 * 1000 export const ENTRY_LIMIT = 300 type ImportCardsInputProps = { - headers: string[] setCards: (cards: Card[]) => void - lineToCard: (line: string[], csvHeader: string[]) => Card - isFreinetFormat: boolean + region: Region } -const ImportCardsInput = ({ setCards, lineToCard, headers, isFreinetFormat }: ImportCardsInputProps): ReactElement => { +const ImportCardsInput = ({ setCards, region }: ImportCardsInputProps): ReactElement => { + const isFreinetFormat = new URLSearchParams(useLocation().search).get(FREINET_PARAM) === 'true' + const projectConfig = useContext(ProjectConfigContext) + const csvHeaders = getCsvHeaders(projectConfig) const [inputState, setInputState] = useState<'loading' | 'error' | 'idle'>('idle') const fileInput = useRef(null) const appToaster = useAppToaster() @@ -57,7 +64,7 @@ const ImportCardsInput = ({ setCards, lineToCard, headers, isFreinetFormat }: Im const lines = content .split('\n') .filter(line => line.trim().length) - .map(line => line.split(/,|;/).map(cell => cell.trim())) + .map(line => line.split(/[,;]/).map(cell => cell.trim())) const numberOfColumns = lines[0]?.length @@ -81,16 +88,16 @@ const ImportCardsInput = ({ setCards, lineToCard, headers, isFreinetFormat }: Im return } - const csvHeader = lines.shift() ?? [] - const cards = lines.map(line => lineToCard(line, csvHeader)) + const [csvHeaders, ...entries] = isFreinetFormat ? convertFreinetImport(lines, projectConfig) : lines + const cards = entries.map(line => initializeCardFromCSV(projectConfig.card, line, csvHeaders, region)) setCards(cards) setInputState('idle') }, - [lineToCard, setCards, showInputError] + [setCards, showInputError, isFreinetFormat, projectConfig, region] ) - const onInputChange: ChangeEventHandler = event => { + const onInputChange = (event: ChangeEvent) => { if (!event.currentTarget.files) { return } @@ -104,7 +111,7 @@ const ImportCardsInput = ({ setCards, lineToCard, headers, isFreinetFormat }: Im } if (file.size > FILE_SIZE_LIMIT_BYTES) { - showInputError('Die ausgewählete Datei ist zu groß.') + showInputError('Die ausgewählte Datei ist zu groß.') return } setInputState('loading') @@ -115,7 +122,7 @@ const ImportCardsInput = ({ setCards, lineToCard, headers, isFreinetFormat }: Im } - description={} + description={} action={ ( @@ -24,7 +24,7 @@ const ImportCardsRequirementsText = ({
  • {isFreinetFormat ? 'Es müssen mindestens die Spalten "vorname", "nachname" und "eak_datum" vorhanden sein' - : `Spaltenformat: ${header.join(', ')}`} + : `Spaltenformat: ${csvHeaders.join(', ')}`}
  • Gültiges Datumsformat: tt.mm.jjjj (Beispiel: 01.01.1970)
  • diff --git a/administration/src/bp-modules/cards/ImportCardsTable.tsx b/administration/src/bp-modules/cards/ImportCardsTable.tsx index b8178658c..85292d785 100644 --- a/administration/src/bp-modules/cards/ImportCardsTable.tsx +++ b/administration/src/bp-modules/cards/ImportCardsTable.tsx @@ -1,16 +1,11 @@ -import { Cell, Column, Table2, TruncatedFormat2 } from '@blueprintjs/table' +import { Cell, Column, Table2, TruncatedFormat } from '@blueprintjs/table' import '@blueprintjs/table/lib/css/table.css' -import React, { ReactElement, useCallback } from 'react' +import React, { ReactElement, useCallback, useContext } from 'react' import styled from 'styled-components' import { Card, getValueByCSVHeader, isValueValid } from '../../cards/Card' -import { CardConfig } from '../../project-configs/getProjectConfig' - -type CardImportTableProps = { - headers: string[] - cards: Card[] - cardConfig: CardConfig -} +import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import { getCsvHeaders } from '../../project-configs/helper' const TableContainer = styled.div` overflow: auto; @@ -25,11 +20,19 @@ const StyledCell = styled(Cell)` white-space: break-spaces; ` -const CardImportTable = ({ headers, cards, cardConfig }: CardImportTableProps): ReactElement => { +type CardImportTableProps = { + cards: Card[] +} + +const CardImportTable = ({ cards }: CardImportTableProps): ReactElement => { + const projectConfig = useContext(ProjectConfigContext) + const { card: cardConfig } = projectConfig + const csvHeaders = getCsvHeaders(projectConfig) + const cellRenderer = useCallback( (rowIndex: number, columnIndex: number) => { const card = cards[rowIndex] - const header = headers[columnIndex] + const header = csvHeaders[columnIndex] const valid = isValueValid(card, cardConfig, header) const value = getValueByCSVHeader(card, cardConfig, header) return ( @@ -38,19 +41,19 @@ const CardImportTable = ({ headers, cards, cardConfig }: CardImportTableProps): key={`${rowIndex}-${columnIndex}`} tooltip={!valid ? 'Validierungsfehler' : undefined} intent={!valid ? 'danger' : 'none'}> - + {value?.toString() || '-'} - + ) }, - [cardConfig, cards, headers] + [cardConfig, cards, csvHeaders] ) return ( - {headers.map(name => ( + {csvHeaders.map(name => ( ))} diff --git a/administration/src/bp-modules/cards/hooks/useCardGenerator.ts b/administration/src/bp-modules/cards/hooks/useCardGenerator.ts index dad84e4f9..68a2a5ab5 100644 --- a/administration/src/bp-modules/cards/hooks/useCardGenerator.ts +++ b/administration/src/bp-modules/cards/hooks/useCardGenerator.ts @@ -13,6 +13,7 @@ import { ProjectConfigContext } from '../../../project-configs/ProjectConfigCont import downloadDataUri from '../../../util/downloadDataUri' import getDeepLinkFromQrCode from '../../../util/getDeepLinkFromQrCode' import { updateArrayItem } from '../../../util/helper' +import normalizeString from '../../../util/normalizeString' import { useAppToaster } from '../../AppToaster' import { ActivityLog } from '../../user-settings/ActivityLog' @@ -174,11 +175,13 @@ const useCardGenerator = (region: Region): UseCardGeneratorReturn => { async (applicationIdToMarkAsProcessed?: number) => { await generateCards( (codes: CreateCardsResult[], cards: Card[]) => generatePdf(codes, cards, projectConfig.pdf, region), - 'berechtigungskarten.pdf', + cards.length === 1 + ? `${normalizeString(cards[0].fullName)}-${new Date().getFullYear()}.pdf` + : 'berechtigungskarten.pdf', applicationIdToMarkAsProcessed ) }, - [projectConfig, region, generateCards] + [projectConfig, region, generateCards, cards] ) const generateCardsCsv = useCallback( diff --git a/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx b/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx index 400c8a0e8..874314a42 100644 --- a/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx +++ b/administration/src/bp-modules/self-service/CardSelfServiceForm.tsx @@ -2,6 +2,7 @@ import { Checkbox, FormGroup, InputGroup, Intent } from '@blueprintjs/core' import InfoOutlined from '@mui/icons-material/InfoOutlined' import { styled } from '@mui/material' import React, { ReactElement, useContext, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { Card, getFullNameValidationErrorMessage, isFullNameValid, isValid } from '../../cards/Card' import ClearInputButton from '../../cards/extensions/components/ClearInputButton' @@ -48,6 +49,7 @@ const CardSelfServiceForm = ({ const [touchedFullName, setTouchedFullName] = useState(false) const [openDataPrivacy, setOpenDataPrivacy] = useState(false) const [openReferenceInformation, setOpenReferenceInformation] = useState(false) + const [_, setSearchParams] = useSearchParams() const cardValid = isValid(card, { expirationDateNullable: true }) const appToaster = useAppToaster() const showErrorMessage = touchedFullName || formSendAttempt @@ -68,6 +70,7 @@ const CardSelfServiceForm = ({ return } await generateCards() + setSearchParams(undefined, { replace: true }) } return ( diff --git a/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx b/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx index a7469dbed..9a3bf2504 100644 --- a/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx +++ b/administration/src/bp-modules/self-service/hooks/useCardGeneratorSelfService.tsx @@ -1,13 +1,15 @@ import { ApolloError } from '@apollo/client' import React, { useCallback, useContext, useState } from 'react' +import { useSearchParams } from 'react-router-dom' -import { Card, generateCardInfo, initializeCard } from '../../../cards/Card' +import { Card, generateCardInfo, initializeCardFromCSV } from '../../../cards/Card' import { generatePdf } from '../../../cards/PdfFactory' import { CreateCardsError, CreateCardsResult } from '../../../cards/createCards' import getMessageFromApolloError from '../../../errors/getMessageFromApolloError' import { DynamicActivationCode, StaticVerificationCode } from '../../../generated/card_pb' import { useCreateCardsFromSelfServiceMutation } from '../../../generated/graphql' import { ProjectConfigContext } from '../../../project-configs/ProjectConfigContext' +import { getCsvHeaders } from '../../../project-configs/helper' import { base64ToUint8Array, uint8ArrayToBase64 } from '../../../util/base64' import downloadDataUri from '../../../util/downloadDataUri' import getCustomDeepLinkFromQrCode from '../../../util/getCustomDeepLinkFromQrCode' @@ -35,9 +37,12 @@ type UseCardGeneratorSelfServiceReturn = { const useCardGeneratorSelfService = (): UseCardGeneratorSelfServiceReturn => { const projectConfig = useContext(ProjectConfigContext) const appToaster = useAppToaster() - const [selfServiceCard, setSelfServiceCard] = useState( - initializeCard(projectConfig.card, undefined, { expirationDate: null }) - ) + const [searchParams] = useSearchParams() + const [selfServiceCard, setSelfServiceCard] = useState(() => { + const headers = getCsvHeaders(projectConfig) + const values = headers.map(header => searchParams.get(header)) + return initializeCardFromCSV(projectConfig.card, values, headers, undefined, true) + }) const [isLoading, setIsLoading] = useState(false) const [selfServiceState, setSelfServiceState] = useState(CardSelfServiceStep.form) const [deepLink, setDeepLink] = useState('') diff --git a/administration/src/bp-modules/cards/ImportCardsFromFreinetController.ts b/administration/src/bp-modules/util/convertFreinetImport.ts similarity index 87% rename from administration/src/bp-modules/cards/ImportCardsFromFreinetController.ts rename to administration/src/bp-modules/util/convertFreinetImport.ts index 48e4bc82a..ba2e3d9d7 100644 --- a/administration/src/bp-modules/cards/ImportCardsFromFreinetController.ts +++ b/administration/src/bp-modules/util/convertFreinetImport.ts @@ -52,16 +52,25 @@ const setCardType = (line: string[], csvHeader: string[], cardTypeColumnName: st * Converts Freinet CSV export data into valid input for CSV import * @param line The line of the Freinet CSV export * @param csvHeader the header of the Freinet CSV Export + * @param projectConfig the config of the current project * * Format of the input must be: * columns with the header "vorname" and "nachname" must exist * column with the header expiration date must exist and be called "eak_datum" * column with the name "inhaber_ehrenamtskarte" can exist, if so it is used, if not the expiration date is used */ -export const convertFreinetImport = (line: string[], csvHeader: string[], projectConfig: ProjectConfig): void => { +const convertFreinetLineAndHeaders = (line: string[], csvHeader: string[], projectConfig: ProjectConfig): void => { mergeFirstAndLastnameIntoNewColumn(line, csvHeader, projectConfig.card.nameColumnName) renameExpirationDateHeader(csvHeader, projectConfig.card.expiryColumnName) if (projectConfig.card.extensionColumnNames[0]) { setCardType(line, csvHeader, projectConfig.card.extensionColumnNames[0], projectConfig.card.expiryColumnName) } } + +const convertFreinetImport = (file: string[][], projectConig: ProjectConfig): string[][] => { + const [headers, ...lines] = file + lines.forEach(it => convertFreinetLineAndHeaders(it, headers, projectConig)) + return [headers, ...lines] +} + +export default convertFreinetImport diff --git a/administration/src/cards/Card.ts b/administration/src/cards/Card.ts index 20dfbb017..3e0240506 100644 --- a/administration/src/cards/Card.ts +++ b/administration/src/cards/Card.ts @@ -162,15 +162,17 @@ export const initializeCardFromCSV = ( cardConfig: CardConfig, line: (string | null)[], headers: string[], - region: Region, + region: Region | undefined, withDefaults = false ): Card => { const defaultCard = withDefaults ? initializeCard(cardConfig, region) - : { fullName: '', expirationDate: null, extensions: { [REGION_EXTENSION_NAME]: region.id } } + : { fullName: '', expirationDate: null, extensions: region ? { [REGION_EXTENSION_NAME]: region.id } : {} } const extensions = headers.reduce((acc, header, index) => { const value = line[index] - const extension = Extensions.find(extension => extension.name === getExtensionNameByCSVHeader(cardConfig, header)) + const extension = cardConfig.extensions.find( + extension => extension.name === getExtensionNameByCSVHeader(cardConfig, header) + ) return extension && value != null ? Object.assign(acc, extension.fromString(value)) : acc }, defaultCard.extensions) diff --git a/administration/src/cards/extensions/BirthdayExtension.tsx b/administration/src/cards/extensions/BirthdayExtension.tsx index 500f89fdd..ccede1cf1 100644 --- a/administration/src/cards/extensions/BirthdayExtension.tsx +++ b/administration/src/cards/extensions/BirthdayExtension.tsx @@ -32,7 +32,7 @@ const BirthdayForm = ({ } const changeBirthday = (date: Date | null) => { - setValue({ birthday: PlainDate.safeFromCustomFormat(date?.toLocaleDateString() ?? null) }) + setValue({ birthday: PlainDate.safeFromLocalDate(date) }) } return ( @@ -62,7 +62,7 @@ const BirthdayExtension: Extension = { }, }), isValid: ({ birthday }: BirthdayExtensionState) => { - if (birthday === null) { + if (!birthday) { return false } const today = PlainDate.fromLocalDate(new Date()) diff --git a/administration/src/project-configs/ProjectConfigContext.tsx b/administration/src/project-configs/ProjectConfigContext.tsx index cb008ede6..5b86c8bda 100644 --- a/administration/src/project-configs/ProjectConfigContext.tsx +++ b/administration/src/project-configs/ProjectConfigContext.tsx @@ -8,5 +8,5 @@ export const ProjectConfigContext = createContext(projectConfig) export const ProjectConfigProvider = ({ children }: { children: ReactNode }): ReactElement => { const Provider = ProjectConfigContext.Provider - return {children} + return {children} } diff --git a/administration/src/project-configs/helper/index.ts b/administration/src/project-configs/helper/index.ts new file mode 100644 index 000000000..e20c86084 --- /dev/null +++ b/administration/src/project-configs/helper/index.ts @@ -0,0 +1,7 @@ +import { ProjectConfig } from '../getProjectConfig' + +export const getCsvHeaders = (projectConfig: ProjectConfig): string[] => [ + projectConfig.card.nameColumnName, + projectConfig.card.expiryColumnName, + ...(projectConfig.card.extensionColumnNames.filter(Boolean) as string[]), +] diff --git a/administration/src/project-configs/koblenz/config.ts b/administration/src/project-configs/koblenz/config.ts index 6306f8fc3..d2084e629 100644 --- a/administration/src/project-configs/koblenz/config.ts +++ b/administration/src/project-configs/koblenz/config.ts @@ -1,3 +1,5 @@ +import { QUERY_PARAM_BIRTHDAY, QUERY_PARAM_KOBLENZ_REFERENCE_NUMBER, QUERY_PARAM_NAME } from 'build-configs' + import BirthdayExtension from '../../cards/extensions/BirthdayExtension' import KoblenzReferenceNumberExtension from '../../cards/extensions/KoblenzReferenceNumberExtension' import { ActivationText } from '../common/ActivationText' @@ -11,9 +13,9 @@ const config: ProjectConfig = { projectId: 'koblenz.sozialpass.app', staticQrCodesEnabled: true, card: { - nameColumnName: 'Name', + nameColumnName: QUERY_PARAM_NAME, expiryColumnName: 'Ablaufdatum', - extensionColumnNames: ['Geburtsdatum', 'Referenznummer'], + extensionColumnNames: [QUERY_PARAM_BIRTHDAY, QUERY_PARAM_KOBLENZ_REFERENCE_NUMBER], defaultValidity: { years: 1 }, extensions: [BirthdayExtension, KoblenzReferenceNumberExtension], }, diff --git a/administration/src/testing/render.tsx b/administration/src/testing/render.tsx new file mode 100644 index 000000000..77c577a02 --- /dev/null +++ b/administration/src/testing/render.tsx @@ -0,0 +1,15 @@ +import { RenderOptions, RenderResult, render } from '@testing-library/react' +import React, { ReactElement, ReactNode } from 'react' +import { MemoryRouter } from 'react-router-dom' + +export const renderWithRouter = (ui: ReactElement, options?: RenderOptions): RenderResult => { + const CustomWrapper = options?.wrapper + const wrapper = CustomWrapper + ? (props: { children: ReactNode }) => ( + + + + ) + : MemoryRouter + return render(ui, { wrapper }) +} diff --git a/administration/src/util/PlainDate.ts b/administration/src/util/PlainDate.ts index 5ddd06384..cad10128d 100644 --- a/administration/src/util/PlainDate.ts +++ b/administration/src/util/PlainDate.ts @@ -41,6 +41,17 @@ class PlainDate { this.day = day } + static constructSafely(value: T | null, construct: (value: T) => PlainDate | null): PlainDate | null { + if (value === null) { + return null + } + try { + return construct(value) + } catch { + return null + } + } + /** * Returns a PlainDate by parsing a string using some custom format * @param value The string to be parsed. @@ -63,14 +74,7 @@ class PlainDate { } static safeFromCustomFormat(value: string | null, format: string = 'dd.MM.yyyy'): PlainDate | null { - if (value === null) { - return null - } - try { - return PlainDate.fromCustomFormat(value, format) - } catch { - return null - } + return PlainDate.constructSafely(value, value => PlainDate.fromCustomFormat(value, format)) } static safeFrom(valueISO8601: string | null): PlainDate | null { @@ -108,6 +112,10 @@ class PlainDate { return new PlainDate(date.getFullYear(), date.getMonth() + 1, date.getDate()) } + static safeFromLocalDate(date: Date | null): PlainDate | null { + return PlainDate.constructSafely(date, PlainDate.fromLocalDate) + } + /** * Returns a new PlainDate corresponding to `this` plus `duration`. * @param duration diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationService.kt index 6c2e115ec..4c556eac5 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationService.kt @@ -5,6 +5,7 @@ import app.ehrenamtskarte.backend.application.database.NOTE_MAX_CHARS import app.ehrenamtskarte.backend.application.database.repos.ApplicationRepository import app.ehrenamtskarte.backend.application.database.repos.ApplicationRepository.getApplicationByApplicationVerificationAccessKey import app.ehrenamtskarte.backend.application.webservice.schema.create.Application +import app.ehrenamtskarte.backend.application.webservice.utils.ApplicationHandler import app.ehrenamtskarte.backend.auth.database.AdministratorEntity import app.ehrenamtskarte.backend.auth.service.Authorizer.mayDeleteApplicationsInRegion import app.ehrenamtskarte.backend.auth.service.Authorizer.mayUpdateApplicationsInRegion @@ -12,14 +13,8 @@ import app.ehrenamtskarte.backend.common.webservice.GraphQLContext import app.ehrenamtskarte.backend.exception.service.ForbiddenException import app.ehrenamtskarte.backend.exception.service.NotFoundException import app.ehrenamtskarte.backend.exception.service.UnauthorizedException -import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidFileSizeException -import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidFileTypeException import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidNoteSizeException -import app.ehrenamtskarte.backend.exception.webservice.exceptions.MailNotSentException -import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotActivatedForApplicationException -import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotFoundException import app.ehrenamtskarte.backend.mail.Mailer -import app.ehrenamtskarte.backend.regions.database.repos.RegionsRepository import com.expediagroup.graphql.generator.annotations.GraphQLDescription import graphql.execution.DataFetcherResult import graphql.schema.DataFetchingEnvironment @@ -35,47 +30,20 @@ class EakApplicationMutationService { project: String, dfe: DataFetchingEnvironment ): DataFetcherResult { - val context = dfe.getContext() - val backendConfig = context.backendConfiguration - val projectConfig = backendConfig.projects.first { it.id == project } - - val region = transaction { RegionsRepository.findByIdInProject(project, regionId) } ?: throw RegionNotFoundException() - if (!region.activatedForApplication) { - throw RegionNotActivatedForApplicationException() - } - // Validate that all files are png, jpeg or pdf files and at most 5MB. - val allowedContentTypes = setOf("application/pdf", "image/png", "image/jpeg") - val maxFileSizeBytes = 5 * 1000 * 1000 - if (!context.files.all { it.contentType in allowedContentTypes }) { - throw InvalidFileTypeException() - } - if (!context.files.all { it.size <= maxFileSizeBytes }) { - throw InvalidFileSizeException() - } + val applicationHandler = ApplicationHandler(dfe.getContext(), application, regionId, project) + val dataFetcherResultBuilder = DataFetcherResult.newResult() - val (applicationEntity, verificationEntities) = transaction { - ApplicationRepository.persistApplication( - application.toJsonField(), - application.extractApplicationVerifications(), - regionId, - context.applicationData, - context.files - ) - } + applicationHandler.validateRegion() + applicationHandler.validateAttachmentTypes() + val isPreVerified = applicationHandler.isValidPreVerifiedApplication() - Mailer.sendApplicationApplicantMail(backendConfig, projectConfig, application.personalData, applicationEntity.accessKey) + val (applicationEntity, verificationEntities) = applicationHandler.saveApplication() - val dataFetcherResultBuilder = DataFetcherResult.newResult() - for (applicationVerification in verificationEntities) { - try { - val applicantName = "${application.personalData.forenames.shortText} ${application.personalData.surname.shortText}" - Mailer.sendApplicationVerificationMail(backendConfig, applicantName, projectConfig, applicationVerification) - } catch (exception: MailNotSentException) { - dataFetcherResultBuilder.error(exception.toError()) - } + if (isPreVerified) { + applicationHandler.setApplicationVerificationToVerifiedNow(verificationEntities) } - Mailer.sendNotificationForApplicationMails(project, backendConfig, projectConfig, regionId) + applicationHandler.sendApplicationMails(applicationEntity, verificationEntities, dataFetcherResultBuilder) return dataFetcherResultBuilder.data(true).build() } @@ -88,7 +56,8 @@ class EakApplicationMutationService { val jwtPayload = context.enforceSignedIn() return transaction { - val application = ApplicationEntity.findById(applicationId) ?: throw NotFoundException("Application not found") + val application = + ApplicationEntity.findById(applicationId) ?: throw NotFoundException("Application not found") val user = AdministratorEntity.findById(jwtPayload.adminId) ?: throw UnauthorizedException() @@ -140,7 +109,8 @@ class EakApplicationMutationService { val jwtPayload = context.enforceSignedIn() return transaction { - val application = ApplicationEntity.findById(applicationId) ?: throw NotFoundException("Application not found") + val application = + ApplicationEntity.findById(applicationId) ?: throw NotFoundException("Application not found") if (noteText.length > NOTE_MAX_CHARS) { throw InvalidNoteSizeException(NOTE_MAX_CHARS) } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/schema/create/WorkAtOrganization.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/schema/create/WorkAtOrganization.kt index 93dc334cc..793277645 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/schema/create/WorkAtOrganization.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/schema/create/WorkAtOrganization.kt @@ -13,7 +13,8 @@ data class WorkAtOrganization( val responsibility: ShortTextInput, val workSinceDate: DateInput, val payment: Boolean, - val certificate: Attachment? + val certificate: Attachment?, + val isAlreadyVerified: Boolean? ) : JsonFieldSerializable { override fun toJsonField(): JsonField { return JsonField( @@ -29,7 +30,12 @@ data class WorkAtOrganization( Type.Boolean, payment ), - certificate?.toJsonField("certificate") + certificate?.toJsonField("certificate"), + JsonField( + "isAlreadyVerified", + Type.Boolean, + isAlreadyVerified ?: false + ) ) ) } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/utils/ApplicationHandler.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/utils/ApplicationHandler.kt new file mode 100644 index 000000000..104794c4a --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/application/webservice/utils/ApplicationHandler.kt @@ -0,0 +1,110 @@ +package app.ehrenamtskarte.backend.application.webservice.utils + +import app.ehrenamtskarte.backend.application.database.ApplicationEntity +import app.ehrenamtskarte.backend.application.database.ApplicationVerificationEntity +import app.ehrenamtskarte.backend.application.database.repos.ApplicationRepository +import app.ehrenamtskarte.backend.application.webservice.schema.create.Application +import app.ehrenamtskarte.backend.common.webservice.GraphQLContext +import app.ehrenamtskarte.backend.exception.service.UnauthorizedException +import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidFileSizeException +import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidFileTypeException +import app.ehrenamtskarte.backend.exception.webservice.exceptions.MailNotSentException +import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotActivatedForApplicationException +import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotFoundException +import app.ehrenamtskarte.backend.mail.Mailer +import app.ehrenamtskarte.backend.regions.database.repos.RegionsRepository +import graphql.execution.DataFetcherResult +import org.jetbrains.exposed.sql.transactions.transaction + +class ApplicationHandler( + private val context: GraphQLContext, + private val application: Application, + private val regionId: Int, + private val project: String +) { + + fun sendApplicationMails( + applicationEntity: ApplicationEntity, + verificationEntities: List, + dataFetcherResultBuilder: DataFetcherResult.Builder + ) { + val backendConfig = context.backendConfiguration + val projectConfig = backendConfig.projects.first { it.id == project } + + Mailer.sendApplicationApplicantMail( + backendConfig, + projectConfig, + application.personalData, + applicationEntity.accessKey + ) + + for (applicationVerification in verificationEntities) { + try { + val applicantName = + "${application.personalData.forenames.shortText} ${application.personalData.surname.shortText}" + Mailer.sendApplicationVerificationMail( + backendConfig, + applicantName, + projectConfig, + applicationVerification + ) + } catch (exception: MailNotSentException) { + dataFetcherResultBuilder.error(exception.toError()) + } + } + Mailer.sendNotificationForApplicationMails(project, backendConfig, projectConfig, regionId) + } + + fun validateAttachmentTypes() { + val allowedContentTypes = setOf("application/pdf", "image/png", "image/jpeg") + val maxFileSizeBytes = 5 * 1000 * 1000 + if (!context.files.all { it.contentType in allowedContentTypes }) { + throw InvalidFileTypeException() + } + if (!context.files.all { it.size <= maxFileSizeBytes }) { + throw InvalidFileSizeException() + } + } + + fun validateRegion() { + val region = transaction { RegionsRepository.findByIdInProject(project, regionId) } + ?: throw RegionNotFoundException() + if (!region.activatedForApplication) { + throw RegionNotActivatedForApplicationException() + } + } + + fun saveApplication(): Pair> { + val (applicationEntity, verificationEntities) = transaction { + ApplicationRepository.persistApplication( + application.toJsonField(), + application.extractApplicationVerifications(), + regionId, + context.applicationData, + context.files + ) + } + return Pair(applicationEntity, verificationEntities) + } + + fun isValidPreVerifiedApplication(): Boolean { + val isAlreadyVerifiedSet = + application.applicationDetails.blueCardEntitlement?.workAtOrganizationsEntitlement?.list?.any { + it.isAlreadyVerified == true + } ?: false + if (isAlreadyVerifiedSet) { + // check if api token is set and valid, if not throw unauthorized exception + // Will be done in #1790 + throw UnauthorizedException() + } + return false + } + + fun setApplicationVerificationToVerifiedNow(verificationEntities: List) { + transaction { + verificationEntities.forEach { + ApplicationRepository.verifyApplicationVerification(it.accessKey) + } + } + } +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/service/CardVerifier.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/service/CardVerifier.kt index dfafe0e4b..f82823acd 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/service/CardVerifier.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/service/CardVerifier.kt @@ -4,10 +4,12 @@ import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.daysSinceEp import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.isOnOrAfterToday import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.isOnOrBeforeToday import app.ehrenamtskarte.backend.cards.database.repos.CardRepository +import app.ehrenamtskarte.backend.userdata.database.UserEntitlementsEntity import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator import org.jetbrains.exposed.sql.transactions.transaction import java.time.Duration import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import javax.crypto.spec.SecretKeySpec @@ -15,24 +17,34 @@ val TIME_STEP: Duration = Duration.ofSeconds(30) const val TOTP_LENGTH = 6 object CardVerifier { - public fun verifyStaticCard(project: String, cardHash: ByteArray, timezone: ZoneId): Boolean { + fun verifyStaticCard(project: String, cardHash: ByteArray, timezone: ZoneId): Boolean { val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false return !isExpired(card.expirationDay, timezone) && isYetValid(card.startDay, timezone) && !card.revoked } - public fun verifyDynamicCard(project: String, cardHash: ByteArray, totp: Int, timezone: ZoneId): Boolean { + fun verifyDynamicCard(project: String, cardHash: ByteArray, totp: Int, timezone: ZoneId): Boolean { val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false return !isExpired(card.expirationDay, timezone) && isYetValid(card.startDay, timezone) && !card.revoked && isTotpValid(totp, card.totpSecret) } - public fun isExpired(expirationDay: Long?, timezone: ZoneId): Boolean { + fun isExpired(expirationDay: Long?, timezone: ZoneId): Boolean { return expirationDay != null && !isOnOrBeforeToday(daysSinceEpochToDate(expirationDay), timezone) } - public fun isYetValid(startDay: Long?, timezone: ZoneId): Boolean { + fun isExtendable(project: String, cardHash: ByteArray): Boolean { + val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false + val expirationDay = card.expirationDay ?: return false + val entitlementId = card.entitlementId ?: return false + + val userEntitlement = transaction { UserEntitlementsEntity.findById(entitlementId) } ?: return false + + return LocalDate.ofEpochDay(expirationDay) < userEntitlement.endDate + } + + private fun isYetValid(startDay: Long?, timezone: ZoneId): Boolean { return startDay === null || isOnOrAfterToday(daysSinceEpochToDate(startDay), timezone) } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/CardQueryService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/CardQueryService.kt index 5a265815d..329ea44ea 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/CardQueryService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/CardQueryService.kt @@ -15,32 +15,22 @@ class CardQueryService { @Deprecated("Deprecated since May 2023 in favor of CardVerificationResultModel that return a current timestamp", ReplaceWith("verifyCardInProjectV2")) @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check") fun verifyCardInProject(project: String, card: CardVerificationModel, dfe: DataFetchingEnvironment): Boolean { - val context = dfe.getContext() - val projectConfig = context.backendConfiguration.getProjectConfig(project) - val cardHash = Base64.getDecoder().decode(card.cardInfoHashBase64) - var verificationResult = false - - if (card.codeType == CodeType.STATIC) { - verificationResult = card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone) - } else if (card.codeType == CodeType.DYNAMIC) { - verificationResult = card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone) - } - Matomo.trackVerification(context.backendConfiguration, projectConfig, context.request, dfe.field.name, cardHash, card.codeType, verificationResult) - return false + return verifyCardInProjectV2(project, card, dfe).valid } - @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check") + @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid, extendable and a timestamp of the last check") fun verifyCardInProjectV2(project: String, card: CardVerificationModel, dfe: DataFetchingEnvironment): CardVerificationResultModel { val context = dfe.getContext() val projectConfig = context.backendConfiguration.getProjectConfig(project) val cardHash = Base64.getDecoder().decode(card.cardInfoHashBase64) - var verificationResult = CardVerificationResultModel(false) - if (card.codeType == CodeType.STATIC) { - verificationResult = CardVerificationResultModel(card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone)) - } else if (card.codeType == CodeType.DYNAMIC) { - verificationResult = CardVerificationResultModel(card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone)) + val isValid = when (card.codeType) { + CodeType.STATIC -> card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone) + CodeType.DYNAMIC -> card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone) } + + val verificationResult = CardVerificationResultModel(isValid, CardVerifier.isExtendable(project, cardHash)) + Matomo.trackVerification(context.backendConfiguration, projectConfig, context.request, dfe.field.name, cardHash, card.codeType, verificationResult.valid) return verificationResult } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/types/CardVerificationResultModel.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/types/CardVerificationResultModel.kt index c9d7f01bd..55d2a43df 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/types/CardVerificationResultModel.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/cards/webservice/schema/types/CardVerificationResultModel.kt @@ -4,5 +4,6 @@ import java.time.Instant data class CardVerificationResultModel( val valid: Boolean, + val extendable: Boolean = false, val verificationTimeStamp: String = Instant.now().toString() ) diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/GraphqlApiTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/GraphqlApiTest.kt index b412ffc9e..cb854e007 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/GraphqlApiTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/GraphqlApiTest.kt @@ -1,13 +1,13 @@ package app.ehrenamtskarte.backend import app.ehrenamtskarte.backend.common.webservice.GraphQLHandler +import app.ehrenamtskarte.backend.helper.GraphqlResponse import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.javalin.Javalin import io.javalin.testtools.HttpClient import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response import java.io.File open class GraphqlApiTest : IntegrationTest() { @@ -19,7 +19,7 @@ open class GraphqlApiTest : IntegrationTest() { } } - protected fun post(client: HttpClient, mutation: String, token: String? = null): Response { + protected fun post(client: HttpClient, mutation: String, token: String? = null): GraphqlResponse { val requestBody = jacksonObjectMapper().writeValueAsString(mapOf("query" to mutation)) .toRequestBody("application/json".toMediaType()) @@ -31,6 +31,7 @@ open class GraphqlApiTest : IntegrationTest() { requestBuilder.addHeader("Authorization", "Bearer $token") } - return client.request(requestBuilder.build()) + val response = client.request(requestBuilder.build()) + return GraphqlResponse(response.code, response.body) } } diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationServiceTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationServiceTest.kt new file mode 100644 index 000000000..d0c06ff1e --- /dev/null +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/application/webservice/EakApplicationMutationServiceTest.kt @@ -0,0 +1,98 @@ +package app.ehrenamtskarte.backend.application.webservice + +import app.ehrenamtskarte.backend.IntegrationTest +import app.ehrenamtskarte.backend.application.database.ApplicationVerifications +import app.ehrenamtskarte.backend.application.database.Applications +import app.ehrenamtskarte.backend.application.webservice.utils.ApplicationHandler +import app.ehrenamtskarte.backend.auth.webservice.JwtPayload +import app.ehrenamtskarte.backend.common.webservice.GraphQLContext +import app.ehrenamtskarte.backend.exception.service.UnauthorizedException +import app.ehrenamtskarte.backend.helper.TestApplicationBuilder +import graphql.schema.DataFetchingEnvironment +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.verify +import jakarta.servlet.http.Part +import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import kotlin.test.assertFailsWith + +internal class EakApplicationMutationServiceTest : IntegrationTest() { + + @BeforeEach + fun setUp() { + transaction { + ApplicationVerifications.deleteAll() + Applications.deleteAll() + } + } + + private val mockDfe = mockk() + private val mockContext = mockk() + private val mockJwtPayload = mockk() + private val mockApplicationData = File("mockFile1") + private val mockFiles = listOf() + + @Test + fun addEakApplication_storesApplicationSuccessfully() { + every { mockDfe.getContext() } returns mockContext + every { mockContext.enforceSignedIn() } returns mockJwtPayload + every { mockContext.backendConfiguration } returns loadTestConfig() + every { mockContext.applicationData } returns mockApplicationData + every { mockContext.files } returns mockFiles + + mockkConstructor(ApplicationHandler::class) + every { anyConstructed().sendApplicationMails(any(), any(), any()) } returns Unit + every { anyConstructed().setApplicationVerificationToVerifiedNow(any()) } returns Unit + + val application = TestApplicationBuilder.build(false) + + val service = EakApplicationMutationService() + transaction { + val applicationsBefore = Applications.selectAll().count() + + val result = service.addEakApplication(1, application, "bayern.ehrenamtskarte.app", mockDfe) + + assertTrue(result.data) + assertEquals(applicationsBefore + 1, Applications.selectAll().count()) + verify(exactly = 1) { anyConstructed().sendApplicationMails(any(), any(), any()) } + verify(exactly = 0) { anyConstructed().setApplicationVerificationToVerifiedNow(any()) } + } + } + + @Test + fun addEakApplication_storesNoApplicationIfIsVerifiedButNoToken() { + every { mockDfe.getContext() } returns mockContext + every { mockContext.enforceSignedIn() } returns mockJwtPayload + every { mockContext.backendConfiguration } returns loadTestConfig() + every { mockContext.applicationData } returns mockApplicationData + every { mockContext.files } returns mockFiles + + mockkConstructor(ApplicationHandler::class) + every { anyConstructed().sendApplicationMails(any(), any(), any()) } returns Unit + + val application = TestApplicationBuilder.build(true) + + val service = EakApplicationMutationService() + assertFailsWith { + transaction { + val applicationsBefore = Applications.selectAll().count() + + val result = service.addEakApplication(1, application, "bayern.ehrenamtskarte.app", mockDfe) + + assertFalse(result.data) + assertEquals(applicationsBefore, Applications.selectAll().count()) + verify(exactly = 0) { anyConstructed().sendApplicationMails(any(), any(), any()) } + verify(exactly = 0) { anyConstructed().setApplicationVerificationToVerifiedNow(any()) } + } + } + } +} diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardFromSelfServiceTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardFromSelfServiceTest.kt index d07b9d08a..c48e6b66a 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardFromSelfServiceTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardFromSelfServiceTest.kt @@ -8,13 +8,12 @@ import app.ehrenamtskarte.backend.helper.ExampleCardInfo import app.ehrenamtskarte.backend.helper.TestData import app.ehrenamtskarte.backend.userdata.database.UserEntitlements import app.ehrenamtskarte.backend.userdata.database.UserEntitlementsEntity -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.javalin.testtools.JavalinTest import io.ktor.util.decodeBase64Bytes import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.LocalDate import kotlin.test.assertEquals @@ -22,11 +21,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.test.fail internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { - @AfterEach + @BeforeEach fun cleanUp() { transaction { Cards.deleteAll() @@ -63,8 +61,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals("Error INVALID_INPUT occurred.", findValuesAsText("message").single()) @@ -84,8 +81,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals("Error USER_ENTITLEMENT_NOT_FOUND occurred.", findValuesAsText("message").single()) @@ -98,7 +94,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { @Test fun `POST returns an error when user entitlements expired`() = JavalinTest.test(app) { _, client -> - TestData.createUserEntitlements( + TestData.createUserEntitlement( userHash = "\$argon2id\$v=19\$m=19456,t=2,p=1\$cr3lP9IMUKNz4BLfPGlAOHq1z98G5/2tTbhDIko35tY", endDate = LocalDate.now().minusDays(1L), regionId = 95 @@ -110,8 +106,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals("Error USER_ENTITLEMENT_EXPIRED occurred.", findValuesAsText("message").single()) @@ -124,7 +119,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { @Test fun `POST returns an error when user entitlements revoked`() = JavalinTest.test(app) { _, client -> - TestData.createUserEntitlements( + TestData.createUserEntitlement( userHash = "\$argon2id\$v=19\$m=19456,t=2,p=1\$cr3lP9IMUKNz4BLfPGlAOHq1z98G5/2tTbhDIko35tY", revoked = true, regionId = 95 @@ -136,8 +131,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals("Error USER_ENTITLEMENT_EXPIRED occurred.", findPath("message").textValue()) @@ -151,12 +145,12 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { @Test fun `POST returns a successful response when cards are created`() = JavalinTest.test(app) { _, client -> val userRegionId = 95 - val userEntitlementId = TestData.createUserEntitlements( + val userEntitlementId = TestData.createUserEntitlement( userHash = "\$argon2id\$v=19\$m=19456,t=2,p=1\$cr3lP9IMUKNz4BLfPGlAOHq1z98G5/2tTbhDIko35tY", regionId = userRegionId ) - val oldDynamicCardId = TestData.createDynamicCard(regionId = userRegionId, entitlementId = userEntitlementId) - val oldStaticCardId = TestData.createStaticCard(regionId = userRegionId, entitlementId = userEntitlementId) + val oldDynamicCardId = TestData.createDynamicCard(entitlementId = userEntitlementId) + val oldStaticCardId = TestData.createStaticCard(entitlementId = userEntitlementId) val encodedCardInfo = ExampleCardInfo.getEncoded(CardInfoTestSample.KoblenzPass) val mutation = createMutation(encodedCardInfo = encodedCardInfo) @@ -164,8 +158,7 @@ internal class CreateCardFromSelfServiceTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() val newDynamicActivationCode = jsonResponse.findPath("dynamicActivationCode").path("cardInfoHashBase64").textValue() val newStaticVerificationCode = jsonResponse.findPath("staticVerificationCode").path("cardInfoHashBase64").textValue() diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardsByCardInfosTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardsByCardInfosTest.kt index c5e83cb45..6084994e5 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardsByCardInfosTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/CreateCardsByCardInfosTest.kt @@ -7,26 +7,24 @@ import app.ehrenamtskarte.backend.cards.database.CodeType import app.ehrenamtskarte.backend.helper.CardInfoTestSample import app.ehrenamtskarte.backend.helper.ExampleCardInfo import app.ehrenamtskarte.backend.helper.TestAdministrators -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.javalin.testtools.JavalinTest import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.test.fail internal class CreateCardsByCardInfosTest : GraphqlApiTest() { private val projectAdmin = TestAdministrators.EAK_PROJECT_ADMIN private val regionAdmin = TestAdministrators.EAK_REGION_ADMIN - @AfterEach + @BeforeEach fun cleanUp() { transaction { Cards.deleteAll() @@ -68,8 +66,7 @@ internal class CreateCardsByCardInfosTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals("Error INVALID_INPUT occurred.", findValuesAsText("message").single()) @@ -85,8 +82,7 @@ internal class CreateCardsByCardInfosTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertTrue(path("data").path("createCardsByCardInfos").get(0).has("dynamicActivationCode")) diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/VerifyCardTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/VerifyCardTest.kt new file mode 100644 index 000000000..57a41a8a6 --- /dev/null +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/cards/VerifyCardTest.kt @@ -0,0 +1,173 @@ +package app.ehrenamtskarte.backend.cards + +import app.ehrenamtskarte.backend.GraphqlApiTest +import app.ehrenamtskarte.backend.cards.database.CardEntity +import app.ehrenamtskarte.backend.cards.database.Cards +import app.ehrenamtskarte.backend.cards.database.CodeType +import app.ehrenamtskarte.backend.cards.webservice.schema.types.CardVerificationResultModel +import app.ehrenamtskarte.backend.helper.TestData +import app.ehrenamtskarte.backend.userdata.database.UserEntitlements +import io.javalin.testtools.JavalinTest +import io.ktor.util.encodeBase64 +import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.time.LocalDate +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +internal class VerifyCardTest : GraphqlApiTest() { + + data class VerifyCardTestCase(val createCard: () -> Int, val valid: Boolean, val extendable: Boolean) + + companion object { + @JvmStatic + fun verifyCardTestCases(): List { + return listOf( + VerifyCardTestCase( + createCard = ::staticValidCardWithoutExpirationDay, + valid = true, + extendable = false + ), + VerifyCardTestCase( + createCard = ::staticExpiredExtendableCard, + valid = false, + extendable = true + ), + VerifyCardTestCase( + createCard = ::staticFutureNonExtendableCard, + valid = false, + extendable = false + ), + VerifyCardTestCase( + createCard = ::staticRevokedExtendableCard, + valid = false, + extendable = true + ), + VerifyCardTestCase( + createCard = ::dynamicExtendableCardWithoutTotpSecret, + valid = false, + extendable = true + ) + ) + } + + private fun staticValidCardWithoutExpirationDay(): Int { + val userId = TestData.createUserEntitlement(regionId = 95) + return TestData.createStaticCard( + entitlementId = userId, + expirationDay = null + ) + } + + private fun staticExpiredExtendableCard(): Int { + val userId = TestData.createUserEntitlement(regionId = 95, endDate = LocalDate.now().plusMonths(2L)) + return TestData.createStaticCard( + entitlementId = userId, + expirationDay = LocalDate.now().minusMonths(1L).toEpochDay() + ) + } + + private fun staticFutureNonExtendableCard(): Int { + val userId = TestData.createUserEntitlement(regionId = 95, endDate = LocalDate.now().plusMonths(1L)) + return TestData.createStaticCard( + entitlementId = userId, + startDay = LocalDate.now().plusMonths(1L).toEpochDay(), + expirationDay = LocalDate.now().plusMonths(2L).toEpochDay() + ) + } + + private fun staticRevokedExtendableCard(): Int { + val userId = TestData.createUserEntitlement(regionId = 95, endDate = LocalDate.now().plusMonths(2L)) + return TestData.createStaticCard( + entitlementId = userId, + expirationDay = LocalDate.now().plusMonths(1L).toEpochDay(), + revoked = true + ) + } + + private fun dynamicExtendableCardWithoutTotpSecret(): Int { + val userId = TestData.createUserEntitlement(regionId = 95, endDate = LocalDate.now().plusMonths(2L)) + return TestData.createDynamicCard( + entitlementId = userId, + expirationDay = LocalDate.now().plusMonths(1L).toEpochDay(), + totpSecret = null + ) + } + } + + @BeforeEach + fun cleanUp() { + transaction { + Cards.deleteAll() + UserEntitlements.deleteAll() + } + } + + @ParameterizedTest + @MethodSource("verifyCardTestCases") + fun `should return whether the card is valid and extendable`(testCase: VerifyCardTestCase) = JavalinTest.test(app) { _, client -> + val card = transaction { CardEntity.findById(testCase.createCard()) ?: error("Test card has not been created") } + val query = createQuery( + cardInfoHash = card.cardInfoHash.encodeBase64(), + codeType = card.codeType + ) + + val response = post(client, query) + + assertEquals(200, response.code) + + val verificationResult = response.toDataObject() + + assertEquals(testCase.valid, verificationResult.valid) + assertEquals(testCase.extendable, verificationResult.extendable) + } + + @Test + fun `should return valid = false and extendable = false when the card doesn't exist`() = JavalinTest.test(app) { _, client -> + val query = createQuery( + cardInfoHash = Random.nextBytes(20).encodeBase64(), + codeType = CodeType.STATIC + ) + + val response = post(client, query) + + assertEquals(200, response.code) + + val verificationResult = response.toDataObject() + + assertFalse(verificationResult.valid) + assertFalse(verificationResult.extendable) + } + + @Test + fun `should return an error when project does not exist`() = JavalinTest.test(app) { _, client -> + val query = createQuery( + project = "non-existent.sozialpass.app", + cardInfoHash = "qwerty", + codeType = CodeType.STATIC + ) + val response = post(client, query) + + assertEquals(404, response.code) + } + + private fun createQuery(project: String = "koblenz.sozialpass.app", cardInfoHash: String, codeType: CodeType, totp: String? = null): String { + return """ + query VerifyCardInProjectV2 { + verifyCardInProjectV2( + project: "$project" + card: { cardInfoHashBase64: "$cardInfoHash", codeType: ${codeType.name}, totp: $totp } + ) { + extendable + valid + verificationTimeStamp + } + } + """.trimIndent() + } +} diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/GraphqlResponse.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/GraphqlResponse.kt new file mode 100644 index 000000000..53b410d50 --- /dev/null +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/GraphqlResponse.kt @@ -0,0 +1,23 @@ +package app.ehrenamtskarte.backend.helper + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import okhttp3.ResponseBody +import kotlin.test.fail + +data class GraphqlResponse( + val code: Int, + val body: ResponseBody? +) { + val objectMapper = jacksonObjectMapper() + + fun json(): JsonNode { + val responseBody = body?.string() ?: fail("Response body is null") + return objectMapper.readTree(responseBody) + } + + inline fun toDataObject(): T { + val result = json().get("data").first() + return objectMapper.treeToValue(result, T::class.java) + } +} diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestApplicationBuilder.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestApplicationBuilder.kt new file mode 100644 index 000000000..00c3ba6dd --- /dev/null +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestApplicationBuilder.kt @@ -0,0 +1,88 @@ +package app.ehrenamtskarte.backend.helper + +import app.ehrenamtskarte.backend.application.webservice.schema.create.Address +import app.ehrenamtskarte.backend.application.webservice.schema.create.Application +import app.ehrenamtskarte.backend.application.webservice.schema.create.ApplicationDetails +import app.ehrenamtskarte.backend.application.webservice.schema.create.ApplicationType +import app.ehrenamtskarte.backend.application.webservice.schema.create.BavariaCardType +import app.ehrenamtskarte.backend.application.webservice.schema.create.BlueCardEntitlement +import app.ehrenamtskarte.backend.application.webservice.schema.create.BlueCardEntitlementType +import app.ehrenamtskarte.backend.application.webservice.schema.create.BlueCardWorkAtOrganizationsEntitlement +import app.ehrenamtskarte.backend.application.webservice.schema.create.Organization +import app.ehrenamtskarte.backend.application.webservice.schema.create.OrganizationContact +import app.ehrenamtskarte.backend.application.webservice.schema.create.PersonalData +import app.ehrenamtskarte.backend.application.webservice.schema.create.WorkAtOrganization +import app.ehrenamtskarte.backend.application.webservice.schema.create.primitives.DateInput +import app.ehrenamtskarte.backend.application.webservice.schema.create.primitives.EmailInput +import app.ehrenamtskarte.backend.application.webservice.schema.create.primitives.ShortTextInput + +class TestApplicationBuilder { + companion object { + fun build(isAlreadyVerified: Boolean): Application { + return Application( + personalData = PersonalData( + forenames = ShortTextInput("John"), + surname = ShortTextInput("Doe"), + dateOfBirth = DateInput("1990-01-01"), + address = Address( + street = ShortTextInput("Example Street"), + houseNumber = ShortTextInput("123"), + postalCode = ShortTextInput("80331"), + addressSupplement = null, + location = ShortTextInput("München"), + country = ShortTextInput("Deutschland") + ), + telephone = ShortTextInput("123456789"), + emailAddress = EmailInput("johndoe@example.com") + ), + applicationDetails = ApplicationDetails( + applicationType = ApplicationType.FIRST_APPLICATION, + cardType = BavariaCardType.BLUE, + givenInformationIsCorrectAndComplete = true, + hasAcceptedEmailUsage = true, + hasAcceptedPrivacyPolicy = true, + wantsDigitalCard = true, + wantsPhysicalCard = false, + blueCardEntitlement = BlueCardEntitlement( + juleicaEntitlement = null, + militaryReserveEntitlement = null, + workAtDepartmentEntitlement = null, + volunteerServiceEntitlement = null, + entitlementType = BlueCardEntitlementType.WORK_AT_ORGANIZATIONS, + workAtOrganizationsEntitlement = BlueCardWorkAtOrganizationsEntitlement( + list = listOf( + WorkAtOrganization( + organization = Organization( + address = Address( + street = ShortTextInput("Example Street"), + houseNumber = ShortTextInput("123"), + postalCode = ShortTextInput("80331"), + location = ShortTextInput("München"), + country = ShortTextInput("Deutschland"), + addressSupplement = null + ), + category = ShortTextInput("Sport"), + contact = OrganizationContact( + name = ShortTextInput("Jane Doe"), + email = EmailInput("jane.doe@sportverein.de"), + telephone = ShortTextInput("0150123456789"), + hasGivenPermission = true + ), + name = ShortTextInput("Sportverein Augsburg-Nord") + ), + amountOfWork = 7.5, + responsibility = ShortTextInput("Trainer"), + workSinceDate = DateInput("2020-10-06"), + payment = false, + certificate = null, + isAlreadyVerified = isAlreadyVerified + ) + ) + ) + ), + goldenCardEntitlement = null + ) + ) + } + } +} diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestData.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestData.kt index 1d7e8831e..6d8b11e27 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestData.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/TestData.kt @@ -12,6 +12,7 @@ import app.ehrenamtskarte.backend.stores.database.Addresses import app.ehrenamtskarte.backend.stores.database.Contacts import app.ehrenamtskarte.backend.stores.database.PhysicalStores import app.ehrenamtskarte.backend.userdata.database.UserEntitlements +import app.ehrenamtskarte.backend.userdata.database.UserEntitlementsEntity import net.postgis.jdbc.geometry.Point import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insertAndGetId @@ -32,13 +33,13 @@ object TestData { type: ApiTokenType ): Int { val tokenHash = PasswordCrypto.hashWithSHA256(token.toByteArray()) + val projectId = findAdmin(creatorId).projectId return transaction { - val admin = AdministratorEntity.findById(creatorId) ?: throw Exception("Test admin $creatorId not found") ApiTokens.insertAndGetId { it[ApiTokens.tokenHash] = tokenHash it[ApiTokens.creatorId] = creatorId it[ApiTokens.expirationDate] = expirationDate - it[projectId] = admin.projectId + it[ApiTokens.projectId] = projectId it[ApiTokens.type] = type }.value } @@ -87,8 +88,8 @@ object TestData { } } - fun createUserEntitlements( - userHash: String, + fun createUserEntitlement( + userHash: String = "dummy", startDate: LocalDate = LocalDate.now().minusDays(1L), endDate: LocalDate = LocalDate.now().plusYears(1L), revoked: Boolean = false, @@ -110,7 +111,6 @@ object TestData { expirationDay: Long? = null, issueDate: Instant = Instant.now(), revoked: Boolean = false, - regionId: Int, issuerId: Int? = null, firstActivationDate: Instant? = null, entitlementId: Int? = null, @@ -123,7 +123,6 @@ object TestData { expirationDay, issueDate, revoked, - regionId, issuerId, CodeType.DYNAMIC, firstActivationDate, @@ -136,7 +135,6 @@ object TestData { expirationDay: Long? = null, issueDate: Instant = Instant.now(), revoked: Boolean = false, - regionId: Int, issuerId: Int? = null, firstActivationDate: Instant? = null, entitlementId: Int? = null, @@ -148,7 +146,6 @@ object TestData { expirationDay, issueDate, revoked, - regionId, issuerId, CodeType.STATIC, firstActivationDate, @@ -163,7 +160,6 @@ object TestData { expirationDay: Long? = null, issueDate: Instant = Instant.now(), revoked: Boolean = false, - regionId: Int, issuerId: Int? = null, codeType: CodeType, firstActivationDate: Instant? = null, @@ -171,6 +167,12 @@ object TestData { startDay: Long? = null ): Int { val fakeCardInfoHash = Random.nextBytes(20) + val regionId = when { + issuerId != null -> findAdmin(issuerId).regionId + ?: throw IllegalStateException("Admin $issuerId must have a region to create a card") + entitlementId != null -> findUserEntitlement(entitlementId).regionId + else -> throw Exception("Either issuerId or entitlementId must be provided to create a card") + } return transaction { Cards.insertAndGetId { it[Cards.activationSecretHash] = activationSecretHash @@ -188,4 +190,12 @@ object TestData { }.value } } + + private fun findAdmin(adminId: Int): AdministratorEntity { + return transaction { AdministratorEntity.findById(adminId) ?: throw Exception("Test admin $adminId not found") } + } + + private fun findUserEntitlement(entitlementId: Int): UserEntitlementsEntity { + return transaction { UserEntitlementsEntity.findById(entitlementId) ?: throw Exception("User entitlement $entitlementId not found") } + } } diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/stores/ImportAcceptingStoresTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/stores/ImportAcceptingStoresTest.kt index 006c59f53..1df43a537 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/stores/ImportAcceptingStoresTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/stores/ImportAcceptingStoresTest.kt @@ -11,24 +11,22 @@ import app.ehrenamtskarte.backend.stores.database.ContactEntity import app.ehrenamtskarte.backend.stores.database.Contacts import app.ehrenamtskarte.backend.stores.database.PhysicalStoreEntity import app.ehrenamtskarte.backend.stores.database.PhysicalStores -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.javalin.testtools.JavalinTest import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.fail internal class ImportAcceptingStoresTest : GraphqlApiTest() { private val projectStoreManager = TestAdministrators.NUERNBERG_PROJECT_STORE_MANAGER private val projectAdmin = TestAdministrators.NUERNBERG_PROJECT_ADMIN - @AfterEach + @BeforeEach fun cleanUp() { transaction { PhysicalStores.deleteAll() @@ -72,8 +70,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(0, findValue("storesCreated").intValue()) @@ -104,8 +101,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(1, findValue("storesCreated").intValue()) @@ -167,8 +163,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(1, findValue("storesCreated").intValue()) @@ -230,8 +225,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(1, findValue("storesCreated").intValue()) @@ -249,7 +243,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { @Test fun `POST returns a successful response if one store has been created and another one has been deleted`() = JavalinTest.test(app) { _, client -> - val oldStore = TestData.createAcceptingStore() + TestData.createAcceptingStore() val newStore = createAcceptingStoreInput( name = "Test store 2", street = "Teststr.", @@ -270,8 +264,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(1, findValue("storesCreated").intValue()) @@ -312,8 +305,7 @@ internal class ImportAcceptingStoresTest : GraphqlApiTest() { assertEquals(200, response.code) - val responseBody = response.body?.string() ?: fail("Response body is null") - val jsonResponse = jacksonObjectMapper().readTree(responseBody) + val jsonResponse = response.json() jsonResponse.apply { assertEquals(0, findValue("storesCreated").intValue()) diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/userdata/UserImportTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/userdata/UserImportTest.kt index fcb791422..734fc07c1 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/userdata/UserImportTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/userdata/UserImportTest.kt @@ -23,7 +23,7 @@ import okhttp3.Response import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File import java.time.LocalDate @@ -44,7 +44,7 @@ internal class UserImportTest : IntegrationTest() { private val admin = TestAdministrators.KOBLENZ_PROJECT_ADMIN - @AfterEach + @BeforeEach fun cleanUp() { transaction { Cards.deleteAll() @@ -368,7 +368,7 @@ internal class UserImportTest : IntegrationTest() { @Test fun `POST returns a successful response and user entitlements are updated in db`() = JavalinTest.test(app) { _, client -> TestData.createApiToken(creatorId = admin.id, type = ApiTokenType.USER_IMPORT) - TestData.createUserEntitlements( + TestData.createUserEntitlement( userHash = TEST_USER_HASH, regionId = 1 ) @@ -402,18 +402,12 @@ internal class UserImportTest : IntegrationTest() { @Test fun `POST returns a successful response and existing cards are revoked when the user entitlement has been revoked`() = JavalinTest.test(app) { _, client -> TestData.createApiToken(creatorId = admin.id, type = ApiTokenType.USER_IMPORT) - val entitlementId = TestData.createUserEntitlements( + val entitlementId = TestData.createUserEntitlement( userHash = TEST_USER_HASH, regionId = 1 ) - val dynamicCardId = TestData.createDynamicCard( - regionId = 1, - entitlementId = entitlementId - ) - val staticCardId = TestData.createStaticCard( - regionId = 1, - entitlementId = entitlementId - ) + val dynamicCardId = TestData.createDynamicCard(entitlementId = entitlementId) + val staticCardId = TestData.createStaticCard(entitlementId = entitlementId) val csvFile = generateCsvFile( TEST_CSV_FILE_PATH, diff --git a/docs/verein360-enpoint.md b/docs/verein360-enpoint.md new file mode 100644 index 000000000..7abcb3dcd --- /dev/null +++ b/docs/verein360-enpoint.md @@ -0,0 +1,158 @@ +# Verein360 Endpoint Documentation + +## Introduction +Verein360/BLSV should be able to send already approved entitlementcard application for their trainers. +An api token that can be generated from the "Digitale Druckerei" account is required to access the endpoint. +All details about the endpoint can be found in the [API Documentation](../specs/backend-api.graphql). + +## Usage + +### Authourization Header + +Set the HTTP `Authorization` header to `Bearer ` to access the endpoint. + +### Call GraphQL Mutation to add an application + +```graphql +mutation addEakApplication($regionId: Int!, $application: ApplicationInput!, $project: String!) { + result: addEakApplication(regionId: $regionId, application: $application, project: $project) +} +``` + +### Sample Mutation Variables + +```json + { + "regionId": 2, // Must be the correct region ID where the card owner lives + "application": { + "applicationDetails": { + "applicationType": "FIRST_APPLICATION", // Must be FIRST_APPLICATION + "cardType": "BLUE", // Must be BLUE + "givenInformationIsCorrectAndComplete": true, // Must be true + "hasAcceptedEmailUsage": true, // true or false + "hasAcceptedPrivacyPolicy": true, // Must be true + "wantsDigitalCard": true, // Must be true + "wantsPhysicalCard": false, // Must be false + "blueCardEntitlement": { + "entitlementType": "WORK_AT_ORGANIZATIONS", // Must be WORK_AT_ORGANIZATIONS + "workAtOrganizationsEntitlement": { + "list": { + "amountOfWork": 7.5, // Amount of hours worked per week on average + "organization": { + "address": { + "country": { + "shortText": "Deutschland" + }, + "houseNumber": { + "shortText": "123" + }, + "location": { + "shortText": "München" + }, + "postalCode": { + "shortText": "80331" + }, + "street": { + "shortText": "Example Street" + } + }, + "category": {"shortText": "Sport"}, // Must be Sport + "contact": { + "email": {"email": "jane.doe@sportverein.de"}, + "hasGivenPermission": true, // Must be true + "name": {"shortText": "Jane Doe"}, + "telephone": {"shortText": "0150123456789"} + }, + "name": { + "shortText": "Sportverein Augsburg-Nord" + } + }, + "payment": false, // Must be false + "responsibility": {"shortText": "Trainer"}, + "workSinceDate": {"date": "2020-10-06"}, // ISO8601 date strings YYYY-MM-DD + "isAlreadyVerified": true // Must be true, so that we can mark the application as verified. + // If this is set to true, but not valid application token is set + // the application is rejected and an error code returned. + } + } + } + }, + "personalData": { + "address": { + "country": { + "shortText": "Deutschland" + }, + "houseNumber": { + "shortText": "123" + }, + "location": { + "shortText": "München" + }, + "postalCode": { + "shortText": "80331" + }, + "street": { + "shortText": "Example Street" + } + }, + "dateOfBirth": { + "date": "1990-01-01" + }, + "emailAddress": { + "email": "johndoe@example.com" + }, + "forenames": { + "shortText": "John" + }, + "surname": { + "shortText": "Doe" + }, + "telephone": { + "shortText": "123456789" + } + } + }, + "project": "bayern.ehrenamtskarte.app" // Must be bayern.ehrenamtskarte.app +} +``` + + +### Get Region ID for application + +#### Get all Regions + +All available regions and their IDs can be obtained by calling the following graphql endpoint: +```graphql +query getRegions($project: String!) { + regions: regionsInProject(project: $project) { + id + prefix + name + } +} +``` +Variables +```json +{ + "project": "bayern.ehrenamtskarte.app" +} +``` + +#### Get Region By Postal Code + +```graphql +query getRegionsByPostalCode($postalCode: String!, $project: String!) { + regions: regionsByPostalCode(postalCode: $postalCode, project: $project) { + id + name + prefix + } +} +``` + +```json +{ + "postalCode": "86152", + "project": "bayern.ehrenamtskarte.app" +} +``` diff --git a/frontend/assets/koblenz/l10n/override_de.json b/frontend/assets/koblenz/l10n/override_de.json index 48ee7c338..483847b62 100644 --- a/frontend/assets/koblenz/l10n/override_de.json +++ b/frontend/assets/koblenz/l10n/override_de.json @@ -5,11 +5,14 @@ "activateDescription": "Sie haben den KoblenzPass bereits beantragt und einen Aktivierungscode erhalten? Scannen Sie den Code hier ein.", "activateTitle": "Pass aktivieren", "applyDescription": "Sie haben noch keinen KoblenzPass? Hier können Sie Ihren KoblenzPass beantragen.", - "cardExpired": "Ihr Pass ist abgelaufen. Unter \"Weitere Aktionen\" können Sie einen Antrag auf Verlängerung stellen.", + "cardExpired": "Ihr Pass ist abgelaufen. Unter \"Weitere Aktionen\" können Sie Ihren Pass verlängern oder einen neuen Pass beantragen.", "cardInvalid": "Ihr Pass ist ungültig. Er 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 seine Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.", "codeRevoked": "Dieser Pass konnte nicht aktiviert werden, da er widerrufen wurde.", + "extendCard": "Pass verlängern", + "extendCardNotificationTitle": "Hinweis zum Ablauf des Passes", + "extendCardNotificationDescription": "Ihr Pass läuft demnächst ab.\nVerlängern Sie ihn jetzt, um Ihre Vorteile weiterhin nutzen zu können.", "moreActionsActivateDescription": "Ihr hinterlegter KoblenzPass bleibt erhalten. Sie können diesen manuell entfernen.", "moreActionsActivateLimitDescription": "Um einen weiteren KoblenzPass hinzuzufügen, müssen Sie zuerst einen vorhandenen KoblenzPass löschen.", "moreActionsActivateTitle": "Weiteren KoblenzPass hinzufügen", diff --git a/frontend/assets/l10n/app_de.json b/frontend/assets/l10n/app_de.json index a9a776c5f..755b41f82 100644 --- a/frontend/assets/l10n/app_de.json +++ b/frontend/assets/l10n/app_de.json @@ -109,6 +109,9 @@ "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.", + "extendCard": "Karte verlängern", + "extendCardNotificationTitle": "Hinweis zum Ablauf der Karte", + "extendCardNotificationDescription": "Ihre Karte läuft demnächst ab.\nVerlängern Sie sie jetzt, um Ihre Vorteile weiterhin nutzen zu können.", "flashOff": "Blitz aus", "flashOn": "Blitz an", "internetRequired": "Eine Internetverbindung wird benötigt.", diff --git a/frontend/assets/l10n/app_en.json b/frontend/assets/l10n/app_en.json index f3adbd979..897140b58 100644 --- a/frontend/assets/l10n/app_en.json +++ b/frontend/assets/l10n/app_en.json @@ -109,6 +109,9 @@ "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.", + "extendCard": "Extend card", + "extendCardNotificationTitle": "Card Expiry Notice", + "extendCardNotificationDescription": "Your card is about to expire.\nExtend it now to continue using your benefits.", "flashOff": "Flash off", "flashOn": "Flash on", "internetRequired": "An internet connection is required.", diff --git a/frontend/build-configs/bayern/index.ts b/frontend/build-configs/bayern/index.ts index e987e8546..2460b8aff 100644 --- a/frontend/build-configs/bayern/index.ts +++ b/frontend/build-configs/bayern/index.ts @@ -1,4 +1,8 @@ -import { ACTIVATION_PATH, BAYERN_PRODUCTION_ID, BAYERN_STAGING_ID } from "../constants" +import { + ACTIVATION_PATH, + BAYERN_PRODUCTION_ID, + BAYERN_STAGING_ID, +} from '../constants' import BuildConfigType, { CommonBuildConfigType } from "../types" import disclaimerText from "./disclaimerText" import publisherText from "./publisherText" @@ -72,7 +76,14 @@ export const bayernCommon: CommonBuildConfigType = { verification: true, favorites: false, }, - applicationUrl: "https://bayern.ehrenamtskarte.app/beantragen", + applicationUrl: { + production: `https://${BAYERN_PRODUCTION_ID}/beantragen`, + staging: `https://${BAYERN_STAGING_ID}/beantragen`, + local : 'http://localhost:3000/beantragen' + }, + applicationQueryKeyName: null, + applicationQueryKeyBirthday: null, + applicationQueryKeyReferenceNumber: null, dataPrivacyPolicyUrl: "https://bayern.ehrenamtskarte.app/data-privacy-policy", publisherAddress: "Bayerisches Staatsministerium\nfür Familie, Arbeit und Soziales\nWinzererstraße 9\n80797 München", diff --git a/frontend/build-configs/constants/index.ts b/frontend/build-configs/constants/index.ts index f5ba76c22..ccf0b4f8c 100644 --- a/frontend/build-configs/constants/index.ts +++ b/frontend/build-configs/constants/index.ts @@ -7,4 +7,8 @@ export const BAYERN_STAGING_ID = 'staging.bayern.ehrenamtskarte.app' export const NUERNBERG_PRODUCTION_ID = 'nuernberg.sozialpass.app' export const NUERNBERG_STAGING_ID = 'staging.nuernberg.sozialpass.app' export const KOBLENZ_PRODUCTION_ID = 'koblenz.sozialpass.app' -export const KOBLENZ_STAGING_ID = 'staging.koblenz.sozialpass.app' \ No newline at end of file +export const KOBLENZ_STAGING_ID = 'staging.koblenz.sozialpass.app' + +export const QUERY_PARAM_NAME = 'Name' +export const QUERY_PARAM_BIRTHDAY = 'Geburtsdatum' +export const QUERY_PARAM_KOBLENZ_REFERENCE_NUMBER = 'Referenznummer' \ No newline at end of file diff --git a/frontend/build-configs/koblenz/index.ts b/frontend/build-configs/koblenz/index.ts index b711b5def..6b1351960 100644 --- a/frontend/build-configs/koblenz/index.ts +++ b/frontend/build-configs/koblenz/index.ts @@ -1,4 +1,10 @@ -import { ACTIVATION_PATH, KOBLENZ_PRODUCTION_ID, KOBLENZ_STAGING_ID } from "../constants" +import { + ACTIVATION_PATH, + KOBLENZ_PRODUCTION_ID, + KOBLENZ_STAGING_ID, + QUERY_PARAM_BIRTHDAY, QUERY_PARAM_KOBLENZ_REFERENCE_NUMBER, + QUERY_PARAM_NAME +} from '../constants' import BuildConfigType, { CommonBuildConfigType } from "../types" import disclaimerText from "./disclaimerText" import publisherText from "./publisherText" @@ -73,7 +79,14 @@ export const koblenzCommon: CommonBuildConfigType = { verification: true, favorites: false, }, - applicationUrl: "https://koblenz.sozialpass.app/erstellen", + applicationUrl: { + production: `https://${KOBLENZ_PRODUCTION_ID}/erstellen`, + staging: `https://${KOBLENZ_STAGING_ID}/erstellen`, + local : 'http://localhost:3000/erstellen' + }, + applicationQueryKeyName: QUERY_PARAM_NAME, + applicationQueryKeyBirthday: QUERY_PARAM_BIRTHDAY, + applicationQueryKeyReferenceNumber: QUERY_PARAM_KOBLENZ_REFERENCE_NUMBER, dataPrivacyPolicyUrl: "https://koblenz.sozialpass.app/data-privacy-policy", publisherAddress: "Stadt Koblenz\nWilli-Hörter-Platz 1\n56068 Koblenz", publisherText, diff --git a/frontend/build-configs/koblenz/publisherText.ts b/frontend/build-configs/koblenz/publisherText.ts index a5cb1c117..48a76c98b 100644 --- a/frontend/build-configs/koblenz/publisherText.ts +++ b/frontend/build-configs/koblenz/publisherText.ts @@ -1,5 +1,4 @@ -export default ` -Herausgegeben von: +export default `Herausgegeben von: Stadt Koblenz diff --git a/frontend/build-configs/nuernberg/index.ts b/frontend/build-configs/nuernberg/index.ts index 474a3f35c..a540c199c 100644 --- a/frontend/build-configs/nuernberg/index.ts +++ b/frontend/build-configs/nuernberg/index.ts @@ -1,4 +1,8 @@ -import { ACTIVATION_PATH, NUERNBERG_PRODUCTION_ID, NUERNBERG_STAGING_ID } from "../constants" +import { + ACTIVATION_PATH, + NUERNBERG_PRODUCTION_ID, + NUERNBERG_STAGING_ID +} from '../constants' import BuildConfigType, { CommonBuildConfigType } from "../types" import disclaimerText from "./disclaimerText" import publisherText from "./publisherText" @@ -72,7 +76,14 @@ export const nuernbergCommon: CommonBuildConfigType = { verification: true, favorites: false, }, - applicationUrl: "https://beantragen.nuernberg.sozialpass.app", + applicationUrl: { + production: 'https://beantragen.nuernberg.sozialpass.app', + staging: 'https://beantragen.nuernberg.sozialpass.app', + local : 'https://beantragen.nuernberg.sozialpass.app' + }, + applicationQueryKeyName: null, + applicationQueryKeyBirthday: null, + applicationQueryKeyReferenceNumber: null, publisherAddress: "Stadt Nürnberg\nAmt für Existenzsicherung\nund soziale Integration - Sozialamt\nDietzstraße 4\n90443 Nürnberg", dataPrivacyPolicyUrl: "https://nuernberg.sozialpass.app/data-privacy-policy", diff --git a/frontend/build-configs/types.ts b/frontend/build-configs/types.ts index a6db5bc58..33767a20e 100644 --- a/frontend/build-configs/types.ts +++ b/frontend/build-configs/types.ts @@ -94,7 +94,14 @@ export type CommonBuildConfigType = { theme: ThemeType categories: number[] featureFlags: FeatureFlagsType - applicationUrl: string + applicationUrl: { + staging: string + production: string + local: string + } + applicationQueryKeyName: string | null, + applicationQueryKeyBirthday: string | null, + applicationQueryKeyReferenceNumber: string | null dataPrivacyPolicyUrl: string publisherAddress: string publisherText: string diff --git a/frontend/graphql_queries/cards/card_verification_by_hash.graphql b/frontend/graphql_queries/cards/card_verification_by_hash.graphql index 24d8ba427..3547cbeed 100644 --- a/frontend/graphql_queries/cards/card_verification_by_hash.graphql +++ b/frontend/graphql_queries/cards/card_verification_by_hash.graphql @@ -1,6 +1,7 @@ query CardVerificationByHash($project: String!, $card: CardVerificationModelInput!) { verifyCardInProjectV2(project: $project, card: $card){ valid, + extendable, verificationTimeStamp } } diff --git a/frontend/ios/ScreenshotTests/ScreenshotTests.swift b/frontend/ios/ScreenshotTests/ScreenshotTests.swift index 78c70f6e1..d48467d0c 100644 --- a/frontend/ios/ScreenshotTests/ScreenshotTests.swift +++ b/frontend/ios/ScreenshotTests/ScreenshotTests.swift @@ -38,49 +38,50 @@ class ScreenshotTests: XCTestCase { } } + @MainActor func testOpenMap() throws { let app = XCUIApplication() setupSnapshot(app) app.launchArguments += ["UI-Testing"] app.launch() - + let element = app.staticTexts["Suche\nTab 2 von 4"] self.waitForElementToAppear(element: element) sleep(60) - snapshot("01Map") } - + + @MainActor func testOpenSearch() throws { let app = XCUIApplication() setupSnapshot(app) app.launchArguments += ["UI-Testing"] app.launch() - + let tabItem = app.staticTexts["Suche\nTab 2 von 4"] self.waitForElementToAppear(element: tabItem) sleep(5) - - tabItem.tap() + tabItem.tap() snapshot("01Search") } - + + @MainActor func testOpenDetail() throws { let app = XCUIApplication() setupSnapshot(app) app.launchArguments += ["UI-Testing"] app.launch() - + let tabItem = app.staticTexts["Suche\nTab 2 von 4"] self.waitForElementToAppear(element: tabItem) sleep(5) - + tabItem.tap() - let search = app.textFields["Tippen, um zu suchen..."] + let search = app.textFields.firstMatch self.waitForElementToAppear(element: search) search.tap() - + // Default Bayern var searchText = "Eiscafe" var category = "Essen/Trinken/Gastronomie" @@ -91,22 +92,28 @@ class ScreenshotTests: XCTestCase { expectedMatch = "Ahorn Apotheke" } + if (self.getBundleId(from: app) == "app.sozialpass.koblenz"){ + searchText = "Ludwig" + category = "Kultur/Museen/Freizeit" + expectedMatch = "Ludwig Museum Koblenz" + } + search.typeText(searchText) search.typeText("\n") // Close keyboard for more space sleep(10) - + app.images.matching(identifier: category).element(boundBy: 0).tap() // on ipads the list element is a "otherElements" element, on iphones it is a "staticTexts" var result = app.descendants(matching: .any).element(matching: NSPredicate(format: "label CONTAINS[c] %@", expectedMatch)) - + if (!result.exists || !result.isHittable) { result = app.otherElements.element(matching: NSPredicate(format: "label CONTAINS[c] %@", expectedMatch)) } sleep(10) result.tap() - + sleep(2) - + snapshot("01Detail") } } diff --git a/frontend/ios/fastlane/README.md b/frontend/ios/fastlane/README.md index 975b4f256..fa3f35206 100644 --- a/frontend/ios/fastlane/README.md +++ b/frontend/ios/fastlane/README.md @@ -31,6 +31,22 @@ Create a release build Deliver iOS App to TestFlight for testers +### ios appstoreconnect_upload + +```sh +[bundle exec] fastlane ios appstoreconnect_upload +``` + +Deliver iOS App to production + +### ios appstoreconnect_promote + +```sh +[bundle exec] fastlane ios appstoreconnect_promote +``` + +Promote the app from testflight to production in App Store Connect. + ### ios snap_bayern ```sh @@ -47,6 +63,14 @@ Generate new localized screenshots +### ios snap_koblenz + +```sh +[bundle exec] fastlane ios snap_koblenz +``` + + + ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. diff --git a/frontend/ios/fastlane/Snapfile b/frontend/ios/fastlane/Snapfile index db9dffd8c..cc45772be 100644 --- a/frontend/ios/fastlane/Snapfile +++ b/frontend/ios/fastlane/Snapfile @@ -1,10 +1,8 @@ devices([ # https://stackoverflow.com/a/52311326 -"iPhone 8 Plus", # 5.5" -"iPhone 11 Pro Max", # 6.5" -"iPhone 14 Pro Max", # 6.7 iOS 16.4" -"iPad Pro (12.9-inch) (6th generation)", #iOS 16.4 -"iPad Pro (12.9-inch) (2nd generation)" +"iPhone 15 Pro Max", # 6.5" +"iPhone 16 Pro Max", # 6.9 +"iPad Pro 13-inch (M4)", # 13" ]) languages([ diff --git a/frontend/ios/fastlane/SnapshotHelper.swift b/frontend/ios/fastlane/SnapshotHelper.swift index da063ba1c..6dec13020 100644 --- a/frontend/ios/fastlane/SnapshotHelper.swift +++ b/frontend/ios/fastlane/SnapshotHelper.swift @@ -15,13 +15,12 @@ import Foundation import XCTest -var deviceLanguage = "" -var locale = "" - +@MainActor func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) } +@MainActor func snapshot(_ name: String, waitForLoadingIndicator: Bool) { if waitForLoadingIndicator { Snapshot.snapshot(name) @@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) { /// - Parameters: /// - name: The name of the snapshot /// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { Snapshot.snapshot(name, timeWaitingForIdle: timeout) } @@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible { } @objcMembers +@MainActor open class Snapshot: NSObject { static var app: XCUIApplication? static var waitForAnimations = true @@ -59,6 +60,8 @@ open class Snapshot: NSObject { static var screenshotsDirectory: URL? { return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) } + static var deviceLanguage = "" + static var currentLocale = "" open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { @@ -103,17 +106,17 @@ open class Snapshot: NSObject { do { let trimCharacterSet = CharacterSet.whitespacesAndNewlines - locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) } catch { NSLog("Couldn't detect/set locale...") } - if locale.isEmpty && !deviceLanguage.isEmpty { - locale = Locale(identifier: deviceLanguage).identifier + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier } - if !locale.isEmpty { - app.launchArguments += ["-AppleLocale", "\"\(locale)\""] + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] } } @@ -281,6 +284,7 @@ private extension XCUIElementQuery { return self.containing(isNetworkLoadingIndicator) } + @MainActor var deviceStatusBars: XCUIElementQuery { guard let app = Snapshot.app else { fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") @@ -306,4 +310,4 @@ private extension CGFloat { // Please don't remove the lines below // They are used to detect outdated configuration files -// SnapshotHelperVersion [1.29] +// SnapshotHelperVersion [1.30] diff --git a/frontend/lib/about/about_page.dart b/frontend/lib/about/about_page.dart index b43a8a3ec..abda07e1b 100644 --- a/frontend/lib/about/about_page.dart +++ b/frontend/lib/about/about_page.dart @@ -30,6 +30,8 @@ class AboutPageState extends State { @override Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorTheme = Theme.of(context).colorScheme; final config = Configuration.of(context); final t = context.t; return FutureBuilder( @@ -58,10 +60,10 @@ class AboutPageState extends State { ), ), Center( - child: Text(packageInfo.appName, style: Theme.of(context).textTheme.headlineSmall), + child: Text(packageInfo.appName, style: textTheme.headlineSmall), ), Center( - child: Text(packageInfo.version, style: Theme.of(context).textTheme.bodyMedium), + child: Text(packageInfo.version, style: TextStyle(color: colorTheme.tertiary)), ), const SizedBox(height: 20), const Divider( @@ -74,18 +76,15 @@ class AboutPageState extends State { child: Column( children: [ Center( - child: Text(t.about.publisher, style: Theme.of(context).textTheme.titleSmall), + child: Text(t.about.publisher, style: 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), + child: Text(buildConfig.publisherAddress), ), Text( t.about.moreInformation, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.merge(TextStyle(color: Theme.of(context).colorScheme.secondary)), + style: textTheme.bodyLarge?.apply(color: colorTheme.secondary), ), ], ), diff --git a/frontend/lib/about/backend_switch_dialog.dart b/frontend/lib/about/backend_switch_dialog.dart index da67b1d6c..ccc292432 100644 --- a/frontend/lib/about/backend_switch_dialog.dart +++ b/frontend/lib/about/backend_switch_dialog.dart @@ -20,6 +20,7 @@ class BackendSwitchDialogState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return SimpleDialog( children: [ Padding( @@ -27,10 +28,10 @@ class BackendSwitchDialogState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Switch Endpoint', style: Theme.of(context).textTheme.headlineMedium), + Text('Switch Endpoint', style: theme.textTheme.titleLarge), IconButton( icon: const Icon(Icons.close), - color: Theme.of(context).appBarTheme.backgroundColor, + color: theme.appBarTheme.backgroundColor, alignment: Alignment.topRight, onPressed: () { Navigator.of(context, rootNavigator: true).pop(); @@ -44,7 +45,7 @@ class BackendSwitchDialogState extends State { child: Column( children: [ Text('Current Endpoint: \n${Configuration.of(context).graphqlUrl}', - style: Theme.of(context).textTheme.bodyMedium), + style: theme.textTheme.bodyLarge?.apply(color: theme.hintColor)), Padding( padding: EdgeInsets.only(top: 10, bottom: 10), child: TextField( @@ -60,10 +61,7 @@ class BackendSwitchDialogState extends State { ), password.toLowerCase() == widget.passwordToUnlock ? ElevatedButton( - style: ElevatedButton.styleFrom( - padding: EdgeInsets.symmetric(horizontal: 50, vertical: 20), - textStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - child: const Text('Switch API'), + child: Text('Switch API'), onPressed: () => switchBackendUrl(context), ) : Container(), diff --git a/frontend/lib/about/dev_settings_view.dart b/frontend/lib/about/dev_settings_view.dart index 0dd5b70c9..50f7acd05 100644 --- a/frontend/lib/about/dev_settings_view.dart +++ b/frontend/lib/about/dev_settings_view.dart @@ -3,7 +3,7 @@ import 'dart:developer'; import 'package:base32/base32.dart'; import 'package:ehrenamtskarte/app.dart'; -import 'package:ehrenamtskarte/build_config/build_config.dart'; +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/card_detail_view/self_verify_card.dart'; @@ -49,6 +49,7 @@ class DevSettingsView extends StatelessWidget { final settings = Provider.of(context); final client = GraphQLProvider.of(context).value; final userCodeModel = Provider.of(context); + final textTheme = Theme.of(context).textTheme; return Padding( padding: const EdgeInsets.all(15.0), child: Column( @@ -89,8 +90,9 @@ class DevSettingsView extends StatelessWidget { onTap: () { showDialog( context: context, - builder: (context) => - SimpleDialog(title: const Text('Settings'), children: [Text(settings.toString())]), + builder: (context) => SimpleDialog(title: const Text('Settings'), children: [ + Text(settings.toString(), style: textTheme.bodySmall), + ]), ); }, ), @@ -128,6 +130,7 @@ class DevSettingsView extends StatelessWidget { showDialog( context: context, builder: (BuildContext context) { + final theme = Theme.of(context); final base64Controller = TextEditingController(); return AlertDialog( scrollable: true, @@ -153,6 +156,7 @@ class DevSettingsView extends StatelessWidget { ), actions: [ TextButton( + style: theme.textButtonTheme.style, child: const Text('Activate Card'), onPressed: () { GoRouter.of(context).push('/$activationRouteName/code#${base64Controller.text}'); diff --git a/frontend/lib/about/language_change.dart b/frontend/lib/about/language_change.dart index 7e91c0fb2..13458a623 100644 --- a/frontend/lib/about/language_change.dart +++ b/frontend/lib/about/language_change.dart @@ -11,12 +11,11 @@ class LanguageChange extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Column(children: [ ...buildConfig.appLocales.map((item) => DecoratedBox( decoration: BoxDecoration( - color: LocaleSettings.currentLocale.languageCode == item - ? Theme.of(context).colorScheme.surfaceVariant - : null), + color: LocaleSettings.currentLocale.languageCode == item ? theme.colorScheme.surfaceVariant : null), child: ListTile( title: Text( nativeLanguageNames[item]!, @@ -36,9 +35,7 @@ class LanguageChange extends StatelessWidget { Navigator.pop(context); messengerState.showSnackBar( SnackBar( - backgroundColor: Theme.of(context).colorScheme.primary, - content: - Text(t.about.languageChangeSuccessful, style: TextStyle(color: Theme.of(context).colorScheme.background)), + content: Text(t.about.languageChangeSuccessful), ), ); } diff --git a/frontend/lib/about/license_page.dart b/frontend/lib/about/license_page.dart index 0802bdce8..470f73fb9 100644 --- a/frontend/lib/about/license_page.dart +++ b/frontend/lib/about/license_page.dart @@ -21,6 +21,7 @@ class CustomLicensePage extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; + final theme = Theme.of(context); return FutureBuilder>( future: LicenseRegistry.licenses.toList(), builder: (BuildContext context, AsyncSnapshot> snapshot) { @@ -61,8 +62,8 @@ class CustomLicensePage extends StatelessWidget { final license = result[index]; final paragraphs = license.licenseParagraphs; return ListTile( - title: Text(license.packageName), - subtitle: Text(t.about.numberLicenses(n: paragraphs.length)), + title: Text(license.packageName, style: theme.textTheme.titleSmall), + subtitle: Text(t.about.numberLicenses(n: paragraphs.length), style: theme.textTheme.bodyMedium), onTap: () { Navigator.push( context, diff --git a/frontend/lib/about/section.dart b/frontend/lib/about/section.dart index 652727715..20abeb97c 100644 --- a/frontend/lib/about/section.dart +++ b/frontend/lib/about/section.dart @@ -8,17 +8,13 @@ class Section extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(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))), - ), + padding: EdgeInsets.only(top: 16, left: 16, right: 16), + child: Text(headline, style: theme.textTheme.bodySmall?.apply(color: theme.colorScheme.secondary))), Column(children: children), const SizedBox(height: 10), ], diff --git a/frontend/lib/activation/deeplink_activation.dart b/frontend/lib/activation/deeplink_activation.dart index 490fc90ad..21551a183 100644 --- a/frontend/lib/activation/deeplink_activation.dart +++ b/frontend/lib/activation/deeplink_activation.dart @@ -129,7 +129,6 @@ class _DeepLinkActivationState extends State { // TODO 1656: Improve error handling!! ScaffoldMessenger.of(context).showSnackBar( SnackBar( - backgroundColor: Theme.of(context).colorScheme.primary, content: Text(t.common.unknownError), ), ); @@ -165,6 +164,7 @@ class _WarningText extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final String cardsInUse = userCodeModel.userCodes.length.toString(); final String maxCardAmount = buildConfig.maxCardAmount.toString(); final text = switch (status) { @@ -180,8 +180,8 @@ class _WarningText extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 8), child: Column( children: [ - Icon(Icons.warning, color: Theme.of(context).colorScheme.secondary), - Text(text, textAlign: TextAlign.center) + Icon(Icons.warning, color: theme.colorScheme.secondary), + Text(text, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium) ], )); } diff --git a/frontend/lib/configuration/settings_model.dart b/frontend/lib/configuration/settings_model.dart index 83eeacda3..5a4ed399b 100644 --- a/frontend/lib/configuration/settings_model.dart +++ b/frontend/lib/configuration/settings_model.dart @@ -63,7 +63,7 @@ class SettingsModel extends ChangeNotifier { } String hideVerificationInfoKey = 'hideVerificationInfo'; - bool get hideVerificationInfo => _getBool(hideVerificationInfoKey) ?? true; + bool get hideVerificationInfo => _getBool(hideVerificationInfoKey) ?? false; Future setHideVerificationInfo({required bool enabled}) async { bool? currentlyHideVerificationInfo = hideVerificationInfo; diff --git a/frontend/lib/identification/activation_workflow/activation_error_dialog.dart b/frontend/lib/identification/activation_workflow/activation_error_dialog.dart index 33796c3a6..bf82076e7 100644 --- a/frontend/lib/identification/activation_workflow/activation_error_dialog.dart +++ b/frontend/lib/identification/activation_workflow/activation_error_dialog.dart @@ -9,13 +9,14 @@ class ActivationErrorDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final t = context.t; return AlertDialog( title: Text(t.identification.activationError), content: SingleChildScrollView( child: ListBody( children: [ - Text(message), + Text(message, style: theme.textTheme.bodyMedium), ], ), ), diff --git a/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart b/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart index bfb1eec2e..2610cff94 100644 --- a/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart +++ b/frontend/lib/identification/activation_workflow/activation_existing_card_dialog.dart @@ -7,19 +7,19 @@ class ActivationExistingCardDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return AlertDialog( - title: const Text('Diese Karte existiert bereits', style: TextStyle(fontSize: 18)), + title: Text('Diese Karte existiert bereits'), content: SingleChildScrollView( child: ListBody( - children: const [ - Text( - 'Diese Karte ist bereits auf ihrem Gerät aktiv.', - ), + children: [ + Text('Diese Karte ist bereits auf ihrem Gerät aktiv.'), ], ), ), actions: [ TextButton( + style: theme.textButtonTheme.style, child: Text(context.t.common.ok), onPressed: () { Navigator.of(context).pop(); 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 433c3b348..5c2128de8 100644 --- a/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart +++ b/frontend/lib/identification/activation_workflow/activation_overwrite_existing_dialog.dart @@ -9,7 +9,7 @@ class ActivationOverwriteExistingDialog extends StatelessWidget { Widget build(BuildContext context) { final t = context.t; return AlertDialog( - title: Text(t.identification.activateCurrentDeviceTitle, style: TextStyle(fontSize: 18)), + title: Text(t.identification.activateCurrentDeviceTitle), content: SingleChildScrollView( child: ListBody( children: [ 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 48016207d..c4b6ac808 100644 --- a/frontend/lib/identification/card_detail_view/card_detail_view.dart +++ b/frontend/lib/identification/card_detail_view/card_detail_view.dart @@ -1,4 +1,5 @@ import 'package:ehrenamtskarte/configuration/configuration.dart'; +import 'package:ehrenamtskarte/identification/card_detail_view/extend_card_notification.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/more_actions_dialog.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/self_verify_card.dart'; import 'package:ehrenamtskarte/identification/id_card/id_card_with_region_query.dart'; @@ -13,6 +14,7 @@ import 'package:ehrenamtskarte/identification/card_detail_view/verification_code import 'package:provider/provider.dart'; class CardDetailView extends StatefulWidget { + final String applicationUrl; final DynamicUserCode userCode; final VoidCallback startActivation; final VoidCallback startVerification; @@ -21,6 +23,7 @@ class CardDetailView extends StatefulWidget { const CardDetailView( {super.key, + required this.applicationUrl, required this.userCode, required this.startActivation, required this.startVerification, @@ -60,12 +63,13 @@ class _CardDetailViewState extends State { Widget build(BuildContext context) { final orientation = MediaQuery.of(context).orientation; + final cardInfo = widget.userCode.info; + final cardVerification = widget.userCode.cardVerification; + final paddedCard = Padding( padding: const EdgeInsets.all(8), child: IdCardWithRegionQuery( - cardInfo: widget.userCode.info, - isExpired: isCardExpired(widget.userCode.info), - isNotYetValid: isCardNotYetValid(widget.userCode.info)), + cardInfo: cardInfo, isExpired: isCardExpired(cardInfo), isNotYetValid: isCardNotYetValid(cardInfo)), ); final qrCodeAndStatus = QrCodeAndStatus( userCode: widget.userCode, @@ -83,7 +87,16 @@ class _CardDetailViewState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Flexible(child: paddedCard), + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!isCardExpired(cardInfo) && isCardExtendable(cardInfo, cardVerification)) + ExtendCardNotification(applicationUrl: widget.applicationUrl), + paddedCard, + ], + )), if (constraints.maxWidth > qrCodeMinWidth * 2) Flexible(child: qrCodeAndStatus) else @@ -98,12 +111,19 @@ class _CardDetailViewState extends State { ) : SingleChildScrollView( child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column(children: [paddedCard, const SizedBox(height: 16), qrCodeAndStatus]), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + if (!isCardExpired(cardInfo) && isCardExtendable(cardInfo, cardVerification)) + ExtendCardNotification(applicationUrl: widget.applicationUrl), + paddedCard, + const SizedBox(height: 16), + qrCodeAndStatus, + ], ), ), - ); + )); } void _onMoreActionsPressed(BuildContext context) { @@ -166,7 +186,6 @@ class QrCodeAndStatus extends StatelessWidget { Widget build(BuildContext context) { final CardStatus status = CardStatus.from(userCode); final t = context.t; - return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( @@ -208,10 +227,11 @@ class QrCodeAndStatus extends StatelessWidget { onPressed: onMoreActionsPressed, child: Text( t.common.moreActions, - style: TextStyle(color: Theme.of(context).colorScheme.secondary), ), ), - ) + ), + // TODO 1802 Fix more actions button not displayed properly + const SizedBox(height: 12), ], ), ); @@ -228,7 +248,7 @@ class _PaddedText extends StatelessWidget { return Container( padding: const EdgeInsets.only(bottom: 4), constraints: const BoxConstraints(maxWidth: 300), - child: Text(text, textAlign: TextAlign.center), + child: Text(text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge), ); } } diff --git a/frontend/lib/identification/card_detail_view/extend_card_notification.dart b/frontend/lib/identification/card_detail_view/extend_card_notification.dart new file mode 100644 index 000000000..52e613904 --- /dev/null +++ b/frontend/lib/identification/card_detail_view/extend_card_notification.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:ehrenamtskarte/l10n/translations.g.dart'; +import 'package:tinycolor2/tinycolor2.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ExtendCardNotification extends StatefulWidget { + final String applicationUrl; + + const ExtendCardNotification({super.key, required this.applicationUrl}); + @override + State createState() => _ExtendCardNotificationState(); +} + +class _ExtendCardNotificationState extends State { + bool _isVisible = true; + + @override + Widget build(BuildContext context) { + if (!_isVisible) return Container(); + + final primaryColor = Theme.of(context).colorScheme.primary; + final backgroundColor = + Theme.of(context).brightness == Brightness.light ? primaryColor.tint(90) : primaryColor.shade(90); + + return Padding( + padding: const EdgeInsets.all(8), + child: Card( + color: backgroundColor, + elevation: 1, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildContent(context), + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + final t = context.t; + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info, color: colorScheme.primary), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.identification.extendCardNotificationTitle, + style: textTheme.bodyLarge, + ), + SizedBox(height: 8), + Text( + t.identification.extendCardNotificationDescription, + style: textTheme.bodyMedium, + ), + SizedBox(height: 8), + FilledButton( + onPressed: () => _openApplication(), + child: Text(t.identification.extendCard.toUpperCase()), + ), + ], + ), + ), + SizedBox(width: 16), + GestureDetector( + onTap: () { + setState(() { + _isVisible = false; + }); + }, + child: Icon(Icons.close, size: 16), + ), + ], + ); + } + + void _openApplication() { + launchUrlString(widget.applicationUrl, mode: LaunchMode.externalApplication); + } +} 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 571bac288..24a121c74 100644 --- a/frontend/lib/identification/card_detail_view/more_actions_dialog.dart +++ b/frontend/lib/identification/card_detail_view/more_actions_dialog.dart @@ -26,6 +26,7 @@ class MoreActionsDialog extends StatelessWidget { final String maxCardAmount = buildConfig.maxCardAmount.toString(); final bool cardLimitIsReached = hasReachedCardLimit(userCodeModel.userCodes); final t = context.t; + final theme = Theme.of(context); return AlertDialog( contentPadding: const EdgeInsets.only(top: 12), @@ -35,7 +36,7 @@ class MoreActionsDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ListTile( - title: Text(t.identification.moreActionsApplyTitle), + title: Text(t.identification.moreActionsApplyTitle, style: TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(t.identification.moreActionsApplyDescription), leading: const Icon(Icons.assignment, size: 36), onTap: () { @@ -44,7 +45,7 @@ class MoreActionsDialog extends StatelessWidget { }, ), ListTile( - title: Text(t.identification.moreActionsVerifyTitle), + title: Text(t.identification.moreActionsVerifyTitle, style: TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(t.identification.moreActionsVerifyDescription), leading: const Icon(Icons.verified, size: 36), onTap: () { @@ -55,10 +56,12 @@ class MoreActionsDialog extends StatelessWidget { 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), + style: TextStyle(color: cardLimitIsReached ? theme.hintColor : null, fontWeight: FontWeight.bold)), + subtitle: Text( + cardLimitIsReached + ? t.identification.moreActionsActivateLimitDescription + : t.identification.moreActionsActivateDescription, + style: TextStyle(color: cardLimitIsReached ? theme.hintColor : null)), leading: Icon(Icons.add_card, size: 36), onTap: () { Navigator.pop(context); @@ -66,7 +69,7 @@ class MoreActionsDialog extends StatelessWidget { }, ), ListTile( - title: Text(t.identification.moreActionsRemoveTitle), + title: Text(t.identification.moreActionsRemoveTitle, style: TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(t.identification.moreActionsRemoveDescription), leading: const Icon(Icons.delete, size: 36), onTap: () { 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 3e0936507..a064d24e7 100644 --- a/frontend/lib/identification/card_detail_view/self_verify_card.dart +++ b/frontend/lib/identification/card_detail_view/self_verify_card.dart @@ -30,6 +30,7 @@ Future selfVerifyCard( ..totpSecret = userCode.totpSecret ..cardVerification = (CardVerification() ..cardValid = cardVerification.valid + ..cardExtendable = cardVerification.extendable ..verificationTimeStamp = secondsSinceEpoch(DateTime.parse(cardVerification.verificationTimeStamp)) ..outOfSync = outOfSync)); } diff --git a/frontend/lib/identification/card_detail_view/verification_code_view.dart b/frontend/lib/identification/card_detail_view/verification_code_view.dart index 91ca85761..6b1444cd5 100644 --- a/frontend/lib/identification/card_detail_view/verification_code_view.dart +++ b/frontend/lib/identification/card_detail_view/verification_code_view.dart @@ -50,6 +50,7 @@ class VerificationCodeViewState extends State { Widget build(BuildContext context) { final otpCode = _otpCode; final userCode = widget.userCode; + final colorTheme = Theme.of(context).colorScheme; final time = DateTime.now().millisecondsSinceEpoch; final animationDuration = otpCode.validUntilMilliSeconds - time; @@ -77,10 +78,8 @@ class VerificationCodeViewState extends State { version: qr.QrVersions.auto, gapless: false, dataModuleStyle: qr.QrDataModuleStyle( - dataModuleShape: qr.QrDataModuleShape.square, - color: Theme.of(context).textTheme.bodyMedium?.color), - eyeStyle: qr.QrEyeStyle( - eyeShape: qr.QrEyeShape.square, color: Theme.of(context).textTheme.bodyMedium?.color), + dataModuleShape: qr.QrDataModuleShape.square, color: colorTheme.tertiary), + eyeStyle: qr.QrEyeStyle(eyeShape: qr.QrEyeShape.square, color: colorTheme.tertiary), ), ), Positioned.fill( diff --git a/frontend/lib/identification/connection_failed_dialog.dart b/frontend/lib/identification/connection_failed_dialog.dart index 8dd205809..39d164918 100644 --- a/frontend/lib/identification/connection_failed_dialog.dart +++ b/frontend/lib/identification/connection_failed_dialog.dart @@ -15,7 +15,7 @@ class ConnectionFailedDialog extends StatelessWidget { title: t.common.connectionFailed, icon: Icons.signal_cellular_connected_no_internet_4_bar, iconColor: Theme.of(context).colorScheme.onBackground, - child: Text(reason), + child: Text(reason, style: Theme.of(context).textTheme.bodyMedium), ); } diff --git a/frontend/lib/identification/id_card/card_content.dart b/frontend/lib/identification/id_card/card_content.dart index e441339cb..0d77acde1 100644 --- a/frontend/lib/identification/id_card/card_content.dart +++ b/frontend/lib/identification/id_card/card_content.dart @@ -1,6 +1,7 @@ import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/identification/id_card/card_header_logo.dart'; import 'package:ehrenamtskarte/identification/id_card/id_card.dart'; +import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/util/color_utils.dart'; import 'package:flutter/material.dart'; @@ -55,13 +56,6 @@ class CardContent extends StatelessWidget { : t.identification.unlimited; } - String? get _formattedBirthday { - final birthday = cardInfo.extensions.hasExtensionBirthday() ? cardInfo.extensions.extensionBirthday.birthday : null; - return birthday != null - ? DateFormat('dd.MM.yyyy').format(DateTime.fromMillisecondsSinceEpoch(0).add(Duration(days: birthday))) - : null; - } - String? get _passId { return cardInfo.extensions.hasExtensionNuernbergPassId() ? cardInfo.extensions.extensionNuernbergPassId.passId.toString() @@ -87,12 +81,14 @@ class CardContent extends StatelessWidget { final cardColor = cardInfo.extensions.extensionBavariaCardType.cardType == BavariaCardType.GOLD ? premiumCardColor : standardCardColor; - final formattedBirthday = _formattedBirthday; + final formattedBirthday = getFormattedBirthday(cardInfo); final passId = _passId; final startDate = _formattedStartDate; return LayoutBuilder( builder: (context, constraints) { + final theme = Theme.of(context); final scaleFactor = constraints.maxWidth / 300; + final bodyTextStyle = theme.textTheme.bodyMedium?.apply(fontSizeFactor: scaleFactor, color: textColor); final currentRegion = region; final headerLeftTitle = buildConfig.cardBranding.headerTitleLeft.isEmpty && currentRegion != null ? '${currentRegion.prefix} ${currentRegion.name}' @@ -187,7 +183,7 @@ class CardContent extends StatelessWidget { alignment: Alignment.topLeft, child: Text( cardInfo.fullName, - style: TextStyle(fontSize: 14 * scaleFactor, color: textColor), + style: bodyTextStyle, textAlign: TextAlign.start, ), ), @@ -199,13 +195,13 @@ class CardContent extends StatelessWidget { if (formattedBirthday != null) Text( formattedBirthday, - style: TextStyle(fontSize: 14 * scaleFactor, color: textColor), + style: bodyTextStyle, textAlign: TextAlign.start, ), if (passId != null) Text( passId, - style: TextStyle(fontSize: 14 * scaleFactor, color: textColor), + style: bodyTextStyle, textAlign: TextAlign.end, ), ], @@ -215,10 +211,9 @@ class CardContent extends StatelessWidget { padding: const EdgeInsets.only(top: 3.0), child: Text( _getCardValidityDate(context, startDate, _getFormattedExpirationDate(context)), - style: TextStyle( - fontSize: 14 * scaleFactor, - color: - isExpired || isNotYetValid ? Theme.of(context).colorScheme.error : textColor), + style: theme.textTheme.bodyMedium?.apply( + fontSizeFactor: scaleFactor, + color: isExpired || isNotYetValid ? theme.colorScheme.error : textColor), textAlign: TextAlign.start, ), ), diff --git a/frontend/lib/identification/id_card/card_header_logo.dart b/frontend/lib/identification/id_card/card_header_logo.dart index 1443076dc..62463f913 100644 --- a/frontend/lib/identification/id_card/card_header_logo.dart +++ b/frontend/lib/identification/id_card/card_header_logo.dart @@ -1,6 +1,6 @@ import 'package:ehrenamtskarte/build_config/build_config.dart'; import 'package:ehrenamtskarte/util/color_utils.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; Color textColor = getColorFromHex(buildConfig.cardBranding.headerTextColor); int fontSize = buildConfig.cardBranding.headerTextFontSize; @@ -33,7 +33,8 @@ class CardHeaderLogo extends StatelessWidget { Text( title, maxLines: 3, - style: TextStyle(fontSize: fontSize * scaleFactor, color: textColor), + style: + TextStyle(fontSize: fontSize * scaleFactor, color: textColor, fontFamily: buildConfig.theme.fontFamily), textAlign: TextAlign.start, ) ], diff --git a/frontend/lib/identification/identification_page.dart b/frontend/lib/identification/identification_page.dart index 7678cf8e7..6a9d0801f 100644 --- a/frontend/lib/identification/identification_page.dart +++ b/frontend/lib/identification/identification_page.dart @@ -7,11 +7,13 @@ import 'package:ehrenamtskarte/identification/card_detail_view/card_detail_view. 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/util/card_info_utils.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/l10n/translations.g.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/routing.dart'; +import 'package:ehrenamtskarte/util/get_application_url.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; @@ -36,7 +38,10 @@ class IdentificationPageState extends State { builder: (context, userCodeModel, child) { if (!userCodeModel.isInitialized) { if (userCodeModel.initializationFailed) { - return SafeArea(child: Center(child: Text(context.t.common.unknownError, textAlign: TextAlign.center))); + return SafeArea( + child: Center( + child: Text(context.t.common.unknownError, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium))); } return Container(); } @@ -44,11 +49,21 @@ class IdentificationPageState extends State { if (userCodeModel.userCodes.isNotEmpty) { final List carouselCards = []; for (var code in userCodeModel.userCodes) { + final applicationUrl = isCardExtendable(code.info, code.cardVerification) + ? getApplicationUrlForCardExtension( + getApplicationUrl(context), + code.info, + buildConfig.applicationQueryKeyName, + buildConfig.applicationQueryKeyBirthday, + buildConfig.applicationQueryKeyReferenceNumber) + : getApplicationUrl(context); + carouselCards.add(CardDetailView( + applicationUrl: applicationUrl, userCode: code, startVerification: () => _showVerificationDialog(context, settings, userCodeModel), startActivation: () => _startActivation(context), - startApplication: _startApplication, + startApplication: () => _startApplication(applicationUrl), openRemoveCardDialog: () => _openRemoveCardDialog(context), )); } @@ -63,7 +78,7 @@ class IdentificationPageState extends State { return NoCardView( startVerification: () => _showVerificationDialog(context, settings, userCodeModel), startActivation: () => _startActivation(context), - startApplication: _startApplication, + startApplication: () => _startApplication(getApplicationUrl(context)), ); }, ); @@ -102,9 +117,9 @@ class IdentificationPageState extends State { handleDeniedCameraPermission(context); } - Future _startApplication() { + Future _startApplication(String applicationUrl) { return launchUrlString( - buildConfig.applicationUrl, + applicationUrl, mode: LaunchMode.externalApplication, ); } diff --git a/frontend/lib/identification/info_dialog.dart b/frontend/lib/identification/info_dialog.dart index 9b8d2e0e3..b96d38f51 100644 --- a/frontend/lib/identification/info_dialog.dart +++ b/frontend/lib/identification/info_dialog.dart @@ -23,11 +23,14 @@ class InfoDialog extends StatelessWidget { return AlertDialog( title: ListTile( leading: Icon(icon, color: iconColor ?? theme.colorScheme.primaryContainer, size: 30), - title: Text(title, style: theme.textTheme.headlineSmall), + title: Text(title), ), content: child, actions: [ - TextButton(onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: Text(t.common.ok)) + TextButton( + onPressed: () => Navigator.of(context, rootNavigator: true).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 c8c65d89e..5766b0c5b 100644 --- a/frontend/lib/identification/no_card_view.dart +++ b/frontend/lib/identification/no_card_view.dart @@ -91,7 +91,7 @@ class _TapableCardWithArea extends StatelessWidget { Expanded( child: Text( title, - style: Theme.of(context).textTheme.titleLarge, + style: theme.textTheme.titleLarge, textAlign: TextAlign.left, ), ), @@ -100,7 +100,7 @@ class _TapableCardWithArea extends StatelessWidget { const SizedBox(height: 16), Text( description, - style: Theme.of(context).textTheme.bodyMedium, + style: theme.textTheme.bodyLarge?.apply(color: theme.hintColor), textAlign: TextAlign.left, ), Align( 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 0c3add04f..ed44ef8d9 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 @@ -8,13 +8,14 @@ class QrCodeCameraPermissionDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final t = context.t; return AlertDialog( - title: Text(t.identification.cameraAccessRequired, style: TextStyle(fontSize: 18)), + title: Text(t.identification.cameraAccessRequired), content: SingleChildScrollView( child: ListBody( children: [ - Text(t.identification.cameraAccessRequiredSettings), + Text(t.identification.cameraAccessRequiredSettings, style: theme.textTheme.bodyMedium), ], ), ), 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 98813ceac..754ddbd21 100644 --- a/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart +++ b/frontend/lib/identification/qr_code_scanner/qr_code_scanner.dart @@ -63,6 +63,7 @@ class _QRViewState extends State { } final controller = hasCameraIssues ? _controllerPredefinedCameraResolution : _controller; final t = context.t; + final theme = Theme.of(context); return Stack( children: [ Column( @@ -85,7 +86,7 @@ class _QRViewState extends State { decoration: ShapeDecoration( shape: QrScannerOverlayShape( borderRadius: 10, - borderColor: Theme.of(context).colorScheme.secondary, + borderColor: theme.colorScheme.secondary, borderLength: 30, borderWidth: 10, cutOutSize: _calculateScanArea(context), @@ -103,7 +104,7 @@ class _QRViewState extends State { children: [ Container( margin: const EdgeInsets.all(8), - child: Text(t.identification.scanQRCode), + child: Text(t.identification.scanQRCode, style: theme.textTheme.bodyLarge), ), 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 1c7bdca70..b8538b0d3 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 @@ -10,6 +10,7 @@ class QrCodeScannerControls extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final t = context.t; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -23,7 +24,7 @@ class QrCodeScannerControls extends StatelessWidget { valueListenable: controller.torchState, builder: (ctx, state, child) => Text( state == TorchState.on ? t.identification.flashOff : t.identification.flashOn, - style: const TextStyle(fontSize: 16), + style: theme.textTheme.titleSmall?.apply(color: theme.colorScheme.primary), ), ), ), @@ -36,7 +37,7 @@ class QrCodeScannerControls extends StatelessWidget { valueListenable: controller.cameraFacingState, builder: (ctx, state, child) => Text( state == CameraFacing.back ? t.identification.selfieCamera : t.identification.standardCamera, - style: const TextStyle(fontSize: 16), + style: theme.textTheme.titleSmall?.apply(color: theme.colorScheme.primary), ), ), ), diff --git a/frontend/lib/identification/util/activate_card.dart b/frontend/lib/identification/util/activate_card.dart index 57d94635c..d3eca15ad 100644 --- a/frontend/lib/identification/util/activate_card.dart +++ b/frontend/lib/identification/util/activate_card.dart @@ -66,7 +66,6 @@ Future activateCard( if (context.mounted) { messengerState.showSnackBar( SnackBar( - backgroundColor: Theme.of(context).colorScheme.primary, content: Text(t.deeplinkActivation.activationSuccessful), ), ); diff --git a/frontend/lib/identification/util/card_info_utils.dart b/frontend/lib/identification/util/card_info_utils.dart index 87b8ac5fd..92025c325 100644 --- a/frontend/lib/identification/util/card_info_utils.dart +++ b/frontend/lib/identification/util/card_info_utils.dart @@ -4,6 +4,7 @@ import 'package:ehrenamtskarte/identification/util/canonical_json.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/util/date_utils.dart'; import 'package:ehrenamtskarte/util/json_canonicalizer.dart'; +import 'package:intl/intl.dart'; extension Hashing on CardInfo { String hash(List pepper) { @@ -21,14 +22,17 @@ extension Hashing on CardInfo { } bool isCardExpired(CardInfo cardInfo) { - final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null; - // Add 24 hours to be valid on the expiration day and 12h to cover UTC+12 - final int toleranceInHours = 36; - return expirationDay == null - ? false - : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) - .add(Duration(days: expirationDay, hours: toleranceInHours)) - .isBefore(DateTime.now()); + final expirationDay = _getExpirationDayWithTolerance(cardInfo); + return expirationDay != null && expirationDay.isBefore(DateTime.now()); +} + +bool isCardExtendable(CardInfo cardInfo, CardVerification cardVerification) { + if (!cardVerification.cardExtendable) return false; + + final expirationDay = _getExpirationDayWithTolerance(cardInfo); + if (expirationDay == null) return false; + + return DateTime.now().isAfter(expirationDay.subtract(Duration(days: 90))); } bool cardWasVerifiedLately(CardVerification cardVerification) { @@ -49,3 +53,20 @@ bool isCardNotYetValid(CardInfo cardInfo) { .add(Duration(days: startingDay)) .isAfter(DateTime.now().toUtc()); } + +DateTime? _getExpirationDayWithTolerance(CardInfo cardInfo) { + final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null; + if (expirationDay == null) return null; + + // Add 24 hours to be valid on the expiration day and 12h to cover UTC+12 + const toleranceInHours = 36; + return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) + .add(Duration(days: expirationDay, hours: toleranceInHours)); +} + +String? getFormattedBirthday(CardInfo cardInfo) { + final birthday = cardInfo.extensions.hasExtensionBirthday() ? cardInfo.extensions.extensionBirthday.birthday : null; + return birthday != null + ? DateFormat('dd.MM.yyyy').format(DateTime.fromMillisecondsSinceEpoch(0).add(Duration(days: birthday))) + : null; +} 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 06569d1fe..8fc59d0de 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 @@ -15,7 +15,7 @@ class NegativeVerificationResultDialog extends StatelessWidget { title: t.identification.notVerified, icon: Icons.error, iconColor: Colors.red, - child: Text(reason), + child: Text(reason, style: Theme.of(context).textTheme.bodyMedium), ); } 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 a923b6413..4b084ae75 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 @@ -38,12 +38,13 @@ class PositiveVerificationResultDialogState extends State[ @@ -60,7 +61,7 @@ class PositiveVerificationResultDialogState extends State[ - TextButton(child: const Text('Abbrechen'), onPressed: () => Navigator.of(context).pop(false)), - TextButton(child: const Text('Löschen'), onPressed: () => removeCard(context)), + TextButton( + child: const Text('Abbrechen'), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + child: const Text('Löschen'), + onPressed: () => removeCard(context), + ), ], ); } 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 a13613c33..34634571e 100644 --- a/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart +++ b/frontend/lib/identification/verification_workflow/dialogs/verification_info_dialog.dart @@ -11,6 +11,7 @@ class VerificationInfoDialog extends StatelessWidget { Widget build(BuildContext context) { final settings = Provider.of(context); final t = context.t; + final theme = Theme.of(context); return AlertDialog( title: Text(t.identification.verifyInfoTitle), content: SingleChildScrollView( @@ -18,17 +19,17 @@ class VerificationInfoDialog extends StatelessWidget { children: [ _EnumeratedListItem( index: 0, - child: Text(t.identification.scanCode), + child: Text(t.identification.scanCode, style: theme.textTheme.bodyLarge), ), - _EnumeratedListItem(index: 1, child: Text(t.identification.checkingCode)), + _EnumeratedListItem(index: 1, child: Text(t.identification.checkingCode, style: theme.textTheme.bodyLarge)), _EnumeratedListItem( index: 2, - child: Text(t.identification.compareWithID), + child: Text(t.identification.compareWithID, style: theme.textTheme.bodyLarge), ), SizedBox(height: 12), Text( t.identification.internetRequired, - style: TextStyle(fontWeight: FontWeight.bold), + style: theme.textTheme.titleSmall, ), ], ), @@ -70,16 +71,17 @@ class _EnumeratedListItem extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: theme.colorScheme.primary, child: Text( '${index + 1}', style: TextStyle( - color: Theme.of(context).colorScheme.background, + color: theme.colorScheme.background, fontWeight: FontWeight.bold, ), ), 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 56be53221..59afa0676 100644 --- a/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart +++ b/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart @@ -32,6 +32,7 @@ class VerificationQrScannerPage extends StatelessWidget { final config = Configuration.of(context); final settings = Provider.of(context); final currentUserCode = userCode; + final theme = Theme.of(context); return Column( children: [ CustomAppBar( @@ -39,7 +40,7 @@ class VerificationQrScannerPage extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.help), - color: Theme.of(context).appBarTheme.foregroundColor, + color: theme.appBarTheme.foregroundColor, onPressed: () async { await settings.setHideVerificationInfo(enabled: false); if (context.mounted) await VerificationInfoDialog.show(context); diff --git a/frontend/lib/intro_slides/intro_screen.dart b/frontend/lib/intro_slides/intro_screen.dart index c6852ccd5..25e4c3ea8 100644 --- a/frontend/lib/intro_slides/intro_screen.dart +++ b/frontend/lib/intro_slides/intro_screen.dart @@ -29,7 +29,6 @@ class IntroScreen extends StatelessWidget { 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, colorIndicator: Colors.grey, diff --git a/frontend/lib/location/dialogs.dart b/frontend/lib/location/dialogs.dart index ce40a59f5..deee39ade 100644 --- a/frontend/lib/location/dialogs.dart +++ b/frontend/lib/location/dialogs.dart @@ -12,8 +12,14 @@ class LocationServiceDialog extends StatelessWidget { title: Text(t.location.activateLocationAccess), content: Text(t.location.activateLocationAccessSettings), actions: [ - TextButton(child: Text(t.common.cancel), onPressed: () => Navigator.of(context).pop(false)), - TextButton(child: Text(t.common.openSettings), 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), + ) ], ); } @@ -27,16 +33,26 @@ class RationaleDialog extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; + final theme = Theme.of(context); return AlertDialog( title: Text(t.location.locationPermission), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [Text(_rationale), Text(t.location.askPermissionsAgain)], + children: [ + Text(_rationale, style: theme.textTheme.bodyLarge), + Text(t.location.askPermissionsAgain, style: theme.textTheme.bodyLarge) + ], ), actions: [ - TextButton(child: Text(t.location.grantPermission), onPressed: () => Navigator.of(context).pop(true)), - TextButton(child: Text(t.common.cancel), 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/main.dart b/frontend/lib/main.dart index 2474a2e44..4f46badcb 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -31,13 +31,7 @@ Future main() async { // 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); + AppLocale.values.forEach(overrideLocale); } debugPrint('Environment: $appEnvironment'); @@ -52,3 +46,23 @@ Future main() async { run(); } } + +Future loadLocaleOverride(AppLocale locale) async { + final path = '${buildConfig.localeOverridePath}/override_${locale.languageCode}.json'; + try { + return await rootBundle.loadString(path); + } on FlutterError catch (e) { + if (e.message.contains('Unable to load asset')) { + debugPrint('Locale override not found at path: $path. The default translation will be used'); + return null; + } + rethrow; + } +} + +Future overrideLocale(AppLocale locale) async { + final content = await loadLocaleOverride(locale); + if (content != null) { + LocaleSettings.overrideTranslations(locale: locale, fileType: FileType.json, content: content); + } +} diff --git a/frontend/lib/map/map/attribution_dialog_item.dart b/frontend/lib/map/map/attribution_dialog_item.dart index 30aa526aa..7209680ec 100644 --- a/frontend/lib/map/map/attribution_dialog_item.dart +++ b/frontend/lib/map/map/attribution_dialog_item.dart @@ -16,6 +16,7 @@ class AttributionDialogItem extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return SimpleDialogOption( onPressed: onPressed, child: Row( @@ -26,7 +27,7 @@ class AttributionDialogItem extends StatelessWidget { Flexible( child: Container( padding: const EdgeInsetsDirectional.only(start: 8.0), - child: Text(text, style: TextStyle(color: color)), + child: Text(text, style: theme.textTheme.bodySmall?.apply(color: color)), ), ), ], diff --git a/frontend/lib/search/filter_bar.dart b/frontend/lib/search/filter_bar.dart index 1601e3608..9f97212cb 100644 --- a/frontend/lib/search/filter_bar.dart +++ b/frontend/lib/search/filter_bar.dart @@ -1,5 +1,5 @@ import 'package:collection/collection.dart'; -import 'package:ehrenamtskarte/build_config/build_config.dart'; +import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/category_assets.dart'; import 'package:ehrenamtskarte/search/filter_bar_button.dart'; import 'package:flutter/material.dart'; @@ -14,7 +14,7 @@ class FilterBar extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - + final theme = Theme.of(context); final sortedCategories = [...categoryAssets(context).where((category) => category.id != 9)]; sortedCategories.sort((a, b) => a.shortName.length.compareTo(b.shortName.length)); sortedCategories.add(categoryAssets(context).where((category) => category.id == 9).single); @@ -29,7 +29,7 @@ class FilterBar extends StatelessWidget { child: Row( children: [ Text(t.search.filterByCategories.toUpperCase(), - maxLines: 1, style: const TextStyle(color: Colors.grey)), + maxLines: 1, style: theme.textTheme.bodyMedium?.apply(color: theme.hintColor)), const Expanded(child: Padding(padding: EdgeInsets.only(left: 8), child: Divider(thickness: 0.7))) ], ), diff --git a/frontend/lib/search/filter_bar_button.dart b/frontend/lib/search/filter_bar_button.dart index c0286e9a5..e2b0e3ccd 100644 --- a/frontend/lib/search/filter_bar_button.dart +++ b/frontend/lib/search/filter_bar_button.dart @@ -87,7 +87,7 @@ class _FilterBarButtonState extends State with SingleTickerProv builder: (context, child) { final color = Color.lerp(theme.colorScheme.background, selectedColor, colorTween.value); return ConstrainedBox( - constraints: BoxConstraints.tightFor(width: width, height: 70), + constraints: BoxConstraints.tightFor(width: width, height: 74), child: Card( margin: EdgeInsets.zero, color: color, @@ -102,24 +102,21 @@ class _FilterBarButtonState extends State with SingleTickerProv widget.onCategoryPress(widget.asset, isSelected); }); }, - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: Column( - children: [ - SvgPicture.asset(widget.asset.icon, width: 40.0, semanticsLabel: widget.asset.name), - Expanded( - child: Container( - alignment: Alignment.center, - child: Text( - widget.asset.shortName, - maxLines: 2, - style: const TextStyle(fontSize: 10), - textAlign: TextAlign.center, - ), + child: Column( + children: [ + SvgPicture.asset(widget.asset.icon, width: 40.0, semanticsLabel: widget.asset.name), + Expanded( + child: Container( + alignment: Alignment.topCenter, + child: Text( + widget.asset.shortName, + maxLines: 2, + style: theme.textTheme.labelSmall, + textAlign: TextAlign.center, ), - ) - ], - ), + ), + ) + ], ), ), ), diff --git a/frontend/lib/search/results_loader.dart b/frontend/lib/search/results_loader.dart index c908febfc..114de1e23 100644 --- a/frontend/lib/search/results_loader.dart +++ b/frontend/lib/search/results_loader.dart @@ -151,13 +151,15 @@ class ResultsLoaderState extends State { Widget _buildErrorWithRetry(BuildContext context) { final t = context.t; + final theme = Theme.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.warning, size: 60, color: Colors.orange), - Text(t.common.checkConnection), + Text(t.common.checkConnection, style: theme.textTheme.bodyMedium), OutlinedButton( + style: theme.textButtonTheme.style, onPressed: _pagingController.retryLastFailedRequest, child: Text(t.common.tryAgain), ) @@ -168,12 +170,13 @@ class ResultsLoaderState extends State { Widget _buildNoItemsFoundIndicator(BuildContext context) { final t = context.t; + final theme = Theme.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.search_off, size: 60, color: Theme.of(context).disabledColor), - Text(t.search.noAcceptingStoresFound), + Icon(Icons.search_off, size: 60, color: theme.disabledColor), + Text(t.search.noAcceptingStoresFound, style: theme.textTheme.bodyMedium), ], ), ); diff --git a/frontend/lib/search/search_page.dart b/frontend/lib/search/search_page.dart index 37f5b5b80..e535ab5d6 100644 --- a/frontend/lib/search/search_page.dart +++ b/frontend/lib/search/search_page.dart @@ -33,6 +33,7 @@ class _SearchPageState extends State { @override Widget build(BuildContext context) { final t = context.t; + final theme = Theme.of(context); return Stack( children: [ CustomScrollView( @@ -50,7 +51,7 @@ class _SearchPageState extends State { children: [ Text( '${t.search.searchResults.toUpperCase()} ${_sortingMode == SortingMode.byDistance ? t.search.nearby : t.search.alphabetically}', - style: const TextStyle(color: Colors.grey), + style: theme.textTheme.bodyMedium?.apply(color: theme.hintColor), ), const Expanded(child: Padding(padding: EdgeInsets.only(left: 8), child: Divider())) ], diff --git a/frontend/lib/store_widgets/accepting_store_summary.dart b/frontend/lib/store_widgets/accepting_store_summary.dart index bb1424060..fca45412e 100644 --- a/frontend/lib/store_widgets/accepting_store_summary.dart +++ b/frontend/lib/store_widgets/accepting_store_summary.dart @@ -92,6 +92,7 @@ class StoreTextOverview extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final t = context.t; final location = store.location; return Expanded( @@ -103,14 +104,14 @@ class StoreTextOverview extends StatelessWidget { store.name ?? t.store.acceptingStore, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, + style: theme.textTheme.bodyLarge, ), const SizedBox(height: 4), Text( store.description ?? t.store.noDescriptionAvailable, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, + style: theme.textTheme.bodyLarge?.apply(color: theme.hintColor), ), if (showTownName && location != null) Text(location, maxLines: 1, overflow: TextOverflow.ellipsis) ], @@ -136,8 +137,10 @@ class DistanceText extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Center( - child: Text(_formatDistance(distance), maxLines: 1, style: Theme.of(context).textTheme.bodyMedium), + child: + Text(_formatDistance(distance), maxLines: 1, style: theme.textTheme.bodyLarge?.apply(color: theme.hintColor)), ); } } diff --git a/frontend/lib/store_widgets/detail/contact_info_row.dart b/frontend/lib/store_widgets/detail/contact_info_row.dart index 4a4f831ee..a4dfe20d4 100644 --- a/frontend/lib/store_widgets/detail/contact_info_row.dart +++ b/frontend/lib/store_widgets/detail/contact_info_row.dart @@ -20,6 +20,7 @@ class ContactInfoRow extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); if (_description.isEmpty) { return const SizedBox.shrink(); } @@ -31,7 +32,7 @@ class ContactInfoRow extends StatelessWidget { child: Container( width: 42, height: 42, - color: iconFillColor ?? Theme.of(context).colorScheme.primary, + color: iconFillColor ?? theme.colorScheme.primary, child: Icon( _icon, size: 28, @@ -44,6 +45,7 @@ class ContactInfoRow extends StatelessWidget { Expanded( child: Text( _description, + style: theme.textTheme.bodyMedium, ), ), ], diff --git a/frontend/lib/store_widgets/detail/detail_app_bar.dart b/frontend/lib/store_widgets/detail/detail_app_bar.dart index a92663f4a..b8a5ac0ba 100644 --- a/frontend/lib/store_widgets/detail/detail_app_bar.dart +++ b/frontend/lib/store_widgets/detail/detail_app_bar.dart @@ -60,6 +60,7 @@ class DetailAppBarBottom extends StatelessWidget { @override Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), alignment: Alignment.bottomLeft, @@ -68,13 +69,13 @@ class DetailAppBarBottom extends StatelessWidget { children: [ Text( categoryName ?? '', - style: Theme.of(context).textTheme.bodyMedium?.apply(color: textColorGrey), + style: textTheme.bodyLarge?.apply(color: textColorGrey), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( title ?? '', - style: Theme.of(context).textTheme.titleLarge?.apply(color: textColor), + style: textTheme.titleLarge?.apply(color: textColor), maxLines: 2, overflow: TextOverflow.ellipsis, ) @@ -106,8 +107,9 @@ class DetailAppBar extends StatelessWidget { final isFavorite = favoritesProvider.isFavorite(storeId); final accentColor = getDarkenedColorForCategory(context, categoryId); - final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; - final backgroundColor = accentColor ?? Theme.of(context).colorScheme.primary; + final theme = Theme.of(context); + final foregroundColor = theme.appBarTheme.foregroundColor; + final backgroundColor = accentColor ?? theme.colorScheme.primary; final textColor = getReadableOnColor(backgroundColor); final textColorGrey = getReadableOnColorSecondary(backgroundColor); diff --git a/frontend/lib/store_widgets/detail/detail_content.dart b/frontend/lib/store_widgets/detail/detail_content.dart index 4911b8f3b..1f338804e 100644 --- a/frontend/lib/store_widgets/detail/detail_content.dart +++ b/frontend/lib/store_widgets/detail/detail_content.dart @@ -30,6 +30,7 @@ class DetailContent extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; + final theme = Theme.of(context); final address = acceptingStore.address; final street = address.street; final location = '${address.postalCode} ${address.location}'; @@ -53,9 +54,9 @@ class DetailContent extends StatelessWidget { if (storeDescription != null) ...[ Text( storeDescription, - style: Theme.of(context).textTheme.bodyLarge, + style: theme.textTheme.bodyLarge, ), - Divider(thickness: 0.7, height: 48, color: Theme.of(context).primaryColorLight), + Divider(thickness: 0.7, height: 48, color: theme.primaryColorLight), ], Column( children: [ @@ -102,7 +103,7 @@ class DetailContent extends StatelessWidget { Divider( thickness: 0.7, height: 48, - color: Theme.of(context).primaryColorLight, + color: theme.primaryColorLight, ), ButtonBar( alignment: MainAxisAlignment.center, diff --git a/frontend/lib/store_widgets/removed_store_content.dart b/frontend/lib/store_widgets/removed_store_content.dart index e219c4329..c8742bb72 100644 --- a/frontend/lib/store_widgets/removed_store_content.dart +++ b/frontend/lib/store_widgets/removed_store_content.dart @@ -13,6 +13,7 @@ class RemovedStoreContent extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final favoritesProvider = Provider.of(context); return Expanded( child: Scaffold( @@ -24,17 +25,18 @@ class RemovedStoreContent extends StatelessWidget { children: [ Text( t.store.acceptingStoreNotAvailable, - style: Theme.of(context).textTheme.bodyLarge, + style: theme.textTheme.bodyLarge, ), Text( t.store.removeDescription, - style: Theme.of(context).textTheme.bodyLarge, + style: theme.textTheme.bodyLarge, ), - Divider(thickness: 0.7, height: 48, color: Theme.of(context).primaryColorLight), + Divider(thickness: 0.7, height: 48, color: theme.primaryColorLight), ButtonBar( alignment: MainAxisAlignment.center, children: [ OutlinedButton( + style: theme.textButtonTheme.style, child: Text(t.store.removeButtonText), onPressed: () async { await _removeFavorite(context, favoritesProvider); diff --git a/frontend/lib/store_widgets/removed_store_summary.dart b/frontend/lib/store_widgets/removed_store_summary.dart index 4f49a17a4..5957a65a5 100644 --- a/frontend/lib/store_widgets/removed_store_summary.dart +++ b/frontend/lib/store_widgets/removed_store_summary.dart @@ -20,6 +20,7 @@ class RemovedStoreSummary extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final t = context.t; return SafeArea( bottom: false, @@ -42,14 +43,14 @@ class RemovedStoreSummary extends StatelessWidget { storeName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, + style: theme.textTheme.bodyLarge, ), const SizedBox(height: 4), Text( t.store.acceptingStoreNotAvailable, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.redAccent), + style: theme.textTheme.bodyLarge?.apply(color: Colors.redAccent), ), ], ), @@ -58,7 +59,7 @@ class RemovedStoreSummary extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: SizedBox( height: double.infinity, - child: Icon(Icons.keyboard_arrow_right, size: 30.0, color: Theme.of(context).disabledColor), + child: Icon(Icons.keyboard_arrow_right, size: 30.0, color: theme.disabledColor), ), ), ], diff --git a/frontend/lib/themes.dart b/frontend/lib/themes.dart index 29fa2c072..6f869eb55 100644 --- a/frontend/lib/themes.dart +++ b/frontend/lib/themes.dart @@ -3,25 +3,49 @@ import 'package:ehrenamtskarte/util/color_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +/// Typography classes +final titleLarge = const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold); +final titleMedium = const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold); +final titleSmall = const TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold); +final bodyLarge = const TextStyle(fontSize: 15.0, fontWeight: FontWeight.normal); +final bodyMedium = const TextStyle(fontSize: 14.0, fontWeight: FontWeight.normal); +final bodySmall = const TextStyle(fontSize: 12.0, fontWeight: FontWeight.normal); +final labelSmall = const TextStyle(fontSize: 10.0, fontWeight: FontWeight.normal); + ThemeData get lightTheme { + final textColor = Colors.black87; final defaultTypography = Typography.blackMountainView; final primaryColor = getColorFromHex(buildConfig.theme.primaryLight); + final backgroundColor = Colors.white; + final lightTheme = ThemeData( fontFamily: buildConfig.theme.fontFamily, colorScheme: ColorScheme.light( primary: primaryColor, secondary: primaryColor, - background: Colors.white, + background: backgroundColor, surfaceVariant: const Color(0xffefefef), surfaceTint: Colors.white54, error: const Color(0xffcc0000), + tertiary: const Color(0xFF505050), ), + dialogTheme: DialogTheme( + titleTextStyle: titleLarge.apply(color: textColor), contentTextStyle: bodyLarge.apply(color: textColor)), + listTileTheme: ListTileThemeData( + titleTextStyle: bodyLarge.apply(color: textColor), + subtitleTextStyle: bodySmall.apply(color: textColor), + ), + snackBarTheme: + SnackBarThemeData(backgroundColor: primaryColor, contentTextStyle: bodyLarge.apply(color: backgroundColor)), textTheme: defaultTypography.copyWith( - headlineMedium: 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)), + headlineSmall: defaultTypography.headlineSmall?.apply(color: textColor), + titleLarge: titleLarge, + titleMedium: titleMedium, + titleSmall: titleSmall, + bodyLarge: bodyLarge, + bodyMedium: bodyMedium, + bodySmall: bodySmall, + labelSmall: labelSmall, ), useMaterial3: true, ); @@ -34,15 +58,26 @@ ThemeData get lightTheme { color: Color(0xffeeeeee), ), appBarTheme: AppBarTheme( - systemOverlayStyle: SystemUiOverlayStyle.light, - backgroundColor: lightTheme.colorScheme.primary, - foregroundColor: Colors.white, - ), + systemOverlayStyle: SystemUiOverlayStyle.dark, + backgroundColor: lightTheme.colorScheme.primary, + foregroundColor: backgroundColor, + titleTextStyle: titleMedium), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle(side: MaterialStatePropertyAll(BorderSide(color: primaryColor, width: 1))), ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(primaryColor), + elevation: MaterialStatePropertyAll(2), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), checkboxTheme: CheckboxThemeData( - checkColor: const MaterialStatePropertyAll(Colors.black), + checkColor: MaterialStatePropertyAll(textColor), fillColor: MaterialStatePropertyAll(primaryColor), ), ); @@ -51,22 +86,32 @@ ThemeData get lightTheme { ThemeData get darkTheme { final defaultTypography = Typography.whiteMountainView; final primaryColor = getColorFromHex(buildConfig.theme.primaryDark); + final backgroundColor = const Color(0xff121212); + final theme = ThemeData( fontFamily: buildConfig.theme.fontFamily, colorScheme: ColorScheme.dark( primary: primaryColor, secondary: primaryColor, - background: const Color(0xff121212), + background: backgroundColor, surfaceVariant: const Color(0xff262626), surfaceTint: Colors.white, error: const Color(0xff8b0000), + tertiary: const Color(0xFFC6C4C4), ), + dialogTheme: DialogTheme(titleTextStyle: titleMedium, contentTextStyle: bodySmall), + listTileTheme: ListTileThemeData(titleTextStyle: bodyLarge, subtitleTextStyle: bodySmall), + snackBarTheme: + SnackBarThemeData(backgroundColor: primaryColor, contentTextStyle: bodyLarge.apply(color: backgroundColor)), textTheme: defaultTypography.copyWith( - headlineMedium: 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)), + titleLarge: titleLarge, + titleMedium: titleMedium, + titleSmall: titleSmall, + bodyLarge: bodyLarge, + bodyMedium: bodyMedium, + bodySmall: bodySmall, + labelSmall: labelSmall, ), useMaterial3: true, ); @@ -78,10 +123,25 @@ ThemeData get darkTheme { dividerTheme: DividerThemeData( color: Color(0xFF505050), ), - appBarTheme: AppBarTheme(systemOverlayStyle: SystemUiOverlayStyle.light, color: theme.colorScheme.primary), + appBarTheme: AppBarTheme( + systemOverlayStyle: SystemUiOverlayStyle.light, + color: theme.colorScheme.primary, + foregroundColor: Colors.white, + titleTextStyle: titleMedium), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle(side: MaterialStatePropertyAll(BorderSide(color: primaryColor, width: 1))), ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(primaryColor), + elevation: MaterialStatePropertyAll(2), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), checkboxTheme: CheckboxThemeData( checkColor: const MaterialStatePropertyAll(Colors.white), fillColor: MaterialStatePropertyAll(primaryColor), diff --git a/frontend/lib/util/get_application_url.dart b/frontend/lib/util/get_application_url.dart new file mode 100644 index 000000000..0a4debcb5 --- /dev/null +++ b/frontend/lib/util/get_application_url.dart @@ -0,0 +1,34 @@ +import 'package:ehrenamtskarte/build_config/build_config.dart'; +import 'package:ehrenamtskarte/configuration/definitions.dart'; +import 'package:ehrenamtskarte/configuration/settings_model.dart'; +import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; +import 'package:ehrenamtskarte/proto/card.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +String getApplicationUrl(BuildContext context) { + final isStagingEnabled = Provider.of(context, listen: false).enableStaging; + final applicationUrl = buildConfig.applicationUrl; + if (isStagingEnabled) { + return applicationUrl.staging; + } + return isProduction() ? applicationUrl.production : applicationUrl.local; +} + +String getApplicationUrlForCardExtension(String applicationUrl, CardInfo cardInfo, String? applicationQueryKeyName, + String? applicationQueryKeyBirthday, String? applicationQueryKeyReferenceNumber) { + if (applicationQueryKeyName != null && + applicationQueryKeyBirthday != null && + applicationQueryKeyReferenceNumber != null) { + final parsedUrl = Uri.parse(applicationUrl); + final queryParams = { + applicationQueryKeyName: cardInfo.fullName, + applicationQueryKeyBirthday: getFormattedBirthday(cardInfo), + applicationQueryKeyReferenceNumber: cardInfo.extensions.hasExtensionKoblenzReferenceNumber() + ? cardInfo.extensions.extensionKoblenzReferenceNumber.referenceNumber + : null, + }; + return parsedUrl.replace(queryParameters: queryParams).toString(); + } + return applicationUrl; +} diff --git a/frontend/lib/util/messenger_utils.dart b/frontend/lib/util/messenger_utils.dart index c60601b06..29093cbdb 100644 --- a/frontend/lib/util/messenger_utils.dart +++ b/frontend/lib/util/messenger_utils.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; void showSnackBar(BuildContext context, String message, [Color? backgroundColor]) { - final primaryColor = Theme.of(context).colorScheme.primary; + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: backgroundColor ?? primaryColor, diff --git a/frontend/lib/widgets/app_bars.dart b/frontend/lib/widgets/app_bars.dart index 6295db2d0..5eda16f31 100644 --- a/frontend/lib/widgets/app_bars.dart +++ b/frontend/lib/widgets/app_bars.dart @@ -15,7 +15,8 @@ class CustomAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; + final theme = Theme.of(context); + final foregroundColor = theme.appBarTheme.foregroundColor; return AppBar( leading: BackButton(color: foregroundColor), title: Text(title), @@ -63,11 +64,12 @@ class CustomSliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; + final theme = Theme.of(context); + final foregroundColor = theme.appBarTheme.foregroundColor; return SliverAppBar( leading: BackButton(color: foregroundColor), iconTheme: IconThemeData(color: foregroundColor), - title: Text(title), + title: Text(title, style: theme.textTheme.titleMedium?.apply(color: theme.appBarTheme.foregroundColor)), pinned: true, ); } @@ -90,7 +92,8 @@ class SearchSliverAppBarState extends State { @override Widget build(BuildContext context) { final t = context.t; - final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; + final theme = Theme.of(context); + final foregroundColor = theme.appBarTheme.foregroundColor; return SliverAppBar( title: TextField( onTapOutside: (PointerDownEvent event) { @@ -101,10 +104,10 @@ class SearchSliverAppBarState extends State { focusNode: focusNode, decoration: InputDecoration.collapsed( hintText: t.search.searchHint, - hintStyle: TextStyle(color: foregroundColor?.withOpacity(0.8)), + hintStyle: theme.textTheme.bodyLarge?.apply(color: foregroundColor), ), cursorColor: foregroundColor, - style: TextStyle(color: foregroundColor), + style: theme.textTheme.bodyLarge?.apply(color: foregroundColor), ), pinned: true, actionsIconTheme: IconThemeData(color: foregroundColor), @@ -113,7 +116,7 @@ class SearchSliverAppBarState extends State { IconButton(icon: const Icon(Icons.clear), onPressed: _clearInput, color: foregroundColor), Padding( padding: const EdgeInsets.only(right: 15.0), - child: Icon(Icons.search, color: foregroundColor?.withOpacity(0.50)), + child: Icon(Icons.search, color: foregroundColor?.withOpacity(0.9)), ), ], ); diff --git a/frontend/lib/widgets/error_message.dart b/frontend/lib/widgets/error_message.dart index b27e05d86..6b5bf1dd1 100644 --- a/frontend/lib/widgets/error_message.dart +++ b/frontend/lib/widgets/error_message.dart @@ -11,7 +11,7 @@ class ErrorMessage extends StatelessWidget { children: [ const Icon(Icons.warning, color: Colors.orange), const SizedBox(width: 10), - Expanded(child: Text(_message)), + Expanded(child: Text(_message, style: Theme.of(context).textTheme.bodyMedium)), const Icon(Icons.replay) ], ); diff --git a/frontend/lib/widgets/extended_floating_action_button.dart b/frontend/lib/widgets/extended_floating_action_button.dart index 1ccde72e4..30f815edf 100644 --- a/frontend/lib/widgets/extended_floating_action_button.dart +++ b/frontend/lib/widgets/extended_floating_action_button.dart @@ -12,8 +12,9 @@ class ExtendedFloatingActionButton extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return FloatingActionButton.extended( - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: theme.colorScheme.surfaceVariant, elevation: 1, onPressed: onPressed, icon: AnimatedSwitcher( @@ -23,12 +24,12 @@ class ExtendedFloatingActionButton extends StatelessWidget { : Icon( icon, size: 24, - color: Theme.of(context).colorScheme.secondary, + color: theme.colorScheme.secondary, ), ), label: Text( label, - style: TextStyle(color: Theme.of(context).hintColor), + style: theme.textTheme.bodyMedium?.apply(color: theme.hintColor), ), ); } diff --git a/frontend/test/get_application_url_test.dart b/frontend/test/get_application_url_test.dart new file mode 100644 index 000000000..814c8ccae --- /dev/null +++ b/frontend/test/get_application_url_test.dart @@ -0,0 +1,58 @@ +import 'package:ehrenamtskarte/proto/card.pb.dart'; +import 'package:ehrenamtskarte/util/get_application_url.dart'; +import 'package:test/test.dart'; + +void main() { + group('getApplicationUrlForCardExtension', () { + test('results in url with queryParams for koblenz', () { + final applicationUrl = 'https://koblenz.sozialpass.app/erstellen'; + final applicationUrlQueryKeyName = 'Name'; + final applicationUrlQueryKeyBirthday = 'Geburtsdatum'; + final applicationUrlQueryKeyReferenceNumber = 'Referenznummer'; + final cardInfo = CardInfo() + ..fullName = 'Karla Koblenz' + ..expirationDay = 365 * 40 // Equals 14.600 + ..extensions = (CardExtensions() + ..extensionBirthday = (BirthdayExtension()..birthday = -365 * 10) + ..extensionKoblenzReferenceNumber = (KoblenzReferenceNumberExtension()..referenceNumber = '123K')); + final applicationUrlWithParameters = getApplicationUrlForCardExtension(applicationUrl, cardInfo, + applicationUrlQueryKeyName, applicationUrlQueryKeyBirthday, applicationUrlQueryKeyReferenceNumber); + expect(applicationUrlWithParameters, + 'https://koblenz.sozialpass.app/erstellen?Name=Karla+Koblenz&Geburtsdatum=04.01.1960&Referenznummer=123K'); + }); + + test('results in url without queryParams if no keys were provided and card info bayern', () { + final applicationUrl = 'https://bayern.ehrenamtskarte.app/beantragen'; + final String? applicationUrlQueryKeyName = null; + final String? applicationUrlQueryKeyBirthday = null; + final String? applicationUrlQueryKeyReferenceNumber = null; + final cardInfo = CardInfo() + ..fullName = 'Max Mustermann' + ..expirationDay = 365 * 40 // Equals 14.600 + ..extensions = (CardExtensions() + ..extensionRegion = (RegionExtension()..regionId = 16) + ..extensionBavariaCardType = (BavariaCardTypeExtension()..cardType = BavariaCardType.STANDARD)); + final applicationUrlWithParameters = getApplicationUrlForCardExtension(applicationUrl, cardInfo, + applicationUrlQueryKeyName, applicationUrlQueryKeyBirthday, applicationUrlQueryKeyReferenceNumber); + expect(applicationUrlWithParameters, 'https://bayern.ehrenamtskarte.app/beantragen'); + }); + + test('results in url without queryParams if no keys were provided and card info nuernberg', () { + final applicationUrl = 'https://beantragen.nuernberg.sozialpass.app'; + final String? applicationUrlQueryKeyName = null; + final String? applicationUrlQueryKeyBirthday = null; + final String? applicationUrlQueryKeyReferenceNumber = null; + final cardInfo = CardInfo() + ..fullName = 'Max Mustermann' + ..expirationDay = 365 * 40 // Equals 14.600 + ..extensions = (CardExtensions() + ..extensionRegion = (RegionExtension()..regionId = 93) + ..extensionBirthday = (BirthdayExtension()..birthday = -365 * 10) + ..extensionNuernbergPassId = (NuernbergPassIdExtension()..passId = 99999999) + ..extensionStartDay = (StartDayExtension()..startDay = 365 * 2)); + final applicationUrlWithParameters = getApplicationUrlForCardExtension(applicationUrl, cardInfo, + applicationUrlQueryKeyName, applicationUrlQueryKeyBirthday, applicationUrlQueryKeyReferenceNumber); + expect(applicationUrlWithParameters, 'https://beantragen.nuernberg.sozialpass.app'); + }); + }); +} diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index 0395ad4db..418745fc7 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -98,6 +98,7 @@ type CardStatisticsResultModel { } type CardVerificationResultModel { + extendable: Boolean! valid: Boolean! verificationTimeStamp: String! } @@ -240,7 +241,7 @@ type Query { searchAcceptingStoresInProject(params: SearchParamsInput!, project: String!): [AcceptingStore!]! "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check" verifyCardInProject(card: CardVerificationModelInput!, project: String!): Boolean! @deprecated(reason : "Deprecated since May 2023 in favor of CardVerificationResultModel that return a current timestamp, replace with verifyCardInProjectV2") - "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check" + "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid, extendable and a timestamp of the last check" verifyCardInProjectV2(card: CardVerificationModelInput!, project: String!): CardVerificationResultModel! "Returns the requesting administrator as retrieved from his JWT token." whoAmI(project: String!): Administrator! @@ -541,6 +542,7 @@ input ShortTextInput { input WorkAtOrganizationInput { amountOfWork: Float! certificate: AttachmentInput + isAlreadyVerified: Boolean organization: OrganizationInput! payment: Boolean! responsibility: ShortTextInput! diff --git a/specs/card.proto b/specs/card.proto index e35742344..2c185c566 100644 --- a/specs/card.proto +++ b/specs/card.proto @@ -91,6 +91,8 @@ message CardVerification { // If the verificationTimeStamp was out of sync with the local date time (at time of request) by at least 30 seconds. optional bool outOfSync = 3; + + optional bool cardExtendable = 4; } message QrCode {