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 {