Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3b5003f6 | ||
|
|
9278c7fc34 | ||
|
|
842b3c3ef4 | ||
|
|
35d8d9a623 | ||
|
|
8af927c0f2 | ||
|
|
72367637aa | ||
|
|
f42aead5ca | ||
|
|
6662ca80d4 | ||
|
|
97c5807cb2 | ||
|
|
1c9887e0df | ||
|
|
3f46088e9a | ||
|
|
d8aa30c606 | ||
|
|
530be36458 | ||
|
|
cc663c7e17 | ||
|
|
55509eccb7 | ||
|
|
a4ae7549ae | ||
|
|
73ff55643f | ||
|
|
2e3e62b896 | ||
|
|
d68ce42635 | ||
|
|
edff87de5f | ||
|
|
40b623dd79 | ||
|
|
df11d8492d | ||
|
|
d5ecdcd3c4 | ||
|
|
e55faeaa95 | ||
|
|
0b588fa4b7 | ||
|
|
138d782ca3 | ||
|
|
0886424526 | ||
|
|
7a95f4098f | ||
|
|
912461751a | ||
|
|
4471d3a716 | ||
|
|
320a41142b | ||
|
|
24f8070b8a | ||
|
|
5079d48905 | ||
|
|
dbaafabcf4 | ||
|
|
891105414a | ||
|
|
39f8325287 | ||
|
|
100f5bc2af | ||
|
|
7aba87368d |
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
@@ -5,22 +5,88 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
|
||||
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
|
||||
for the full architecture and phased roadmap.
|
||||
|
||||
This repo holds the **Flutter clients**. The backend (NestJS API) moved to
|
||||
its own repo: <https://git.konfi-castle.com/linus/KC-APP-Server>.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
See the [KC-APP-Server README](https://git.konfi-castle.com/linus/KC-APP-Server)
|
||||
for the backend/Docker setup. It expects a pre-built web bundle:
|
||||
|
||||
```bash
|
||||
(cd client/app && flutter build web --release)
|
||||
```
|
||||
|
||||
By default the server's `docker-compose.yml` mounts `../KC-APP/client/app/build/web`
|
||||
(sibling checkout); override with `WEB_CLIENT_BUILD_PATH` if your layout
|
||||
differs.
|
||||
|
||||
## Structure
|
||||
|
||||
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
|
||||
server, guest/Konfi local accounts, roles/permissions foundation). See
|
||||
[backend/README.md](backend/README.md) for setup.
|
||||
- `client/` — planned Flutter app (mobile + web + desktop), not yet
|
||||
scaffolded (Flutter is not installed in this environment).
|
||||
- `client/app/` — the Flutter client (single codebase; **web** target
|
||||
enabled, mobile/desktop can be added later). Login (guest / local Teamer /
|
||||
invite redemption), role-aware home, guest Workshop-Wahl, file list,
|
||||
read-only chat. See [client/app/README.md](client/app/README.md).
|
||||
- `client/web/` — minimal dependency-free HTML/CSS/JS placeholder web
|
||||
client, served by the backend at `/`. Superseded by the Flutter web build;
|
||||
kept for now as a zero-dependency fallback.
|
||||
|
||||
## Status
|
||||
|
||||
Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc,
|
||||
Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung,
|
||||
File, Chat), Authentik JWT resource-server strategy, guest invite-code login,
|
||||
Role-based guard scoped per KC. Backend builds and boots cleanly
|
||||
(`npm run build`, `node dist/main.js`) but requires a real PostgreSQL
|
||||
database and Authentik instance (see `backend/.env.example`) to run end to
|
||||
end. Remaining phases (Wahl-Engine, Dateifreigabe, Chat realtime, Lokal/Cloud
|
||||
Sync, Flutter clients) are not yet implemented.
|
||||
Role-based guard scoped per KC.
|
||||
|
||||
Phase 2 (Workshop-Wahl engine) implemented: Wahl/Workshop administration,
|
||||
guest Teilnehmer submission, Force-Zuteilung overrides, and the assignment
|
||||
algorithm ported from the WP plugin's `kc_run_zuteilung` (force-assignments →
|
||||
wish rounds 1-3 → random fill → consolidation of underfilled workshops),
|
||||
plus CSV export.
|
||||
|
||||
Phase 3 (Dateifreigabe) implemented: Leitungsteam-only upload tagged with a
|
||||
visibility tier (alle / alle außer Konfis / nur LT), list/download for
|
||||
Authentik or guest callers filtered by their allowed tiers, storage behind a
|
||||
provider abstraction defaulting to Nextcloud/WebDAV (S3-compatible storage
|
||||
as an alternative via `STORAGE_PROVIDER=s3`).
|
||||
|
||||
Phase 5 (Kommunikation) implemented: Gemeinde-Gruppenchat, 1:1-DMs, LT-
|
||||
kanalübergreifende Kanäle, Broadcast (read-only für Konfis); channel/history
|
||||
via REST, real-time send/receive via a raw WebSocket gateway authenticated
|
||||
with the same Authentik/guest tokens as the REST API.
|
||||
|
||||
Phase 6 (Hybrid Lokal/Cloud-Server & Sync) implemented: an append-only
|
||||
replication log (`SyncLogEntry`) captured by every feature service after its
|
||||
writes; the local (on-site) server periodically pushes/pulls against the
|
||||
cloud server's `/sync/ingest` + `/sync/export` endpoints (shared-secret
|
||||
authenticated, not user auth). No conflict resolution needed by design - the
|
||||
local server is the sole source of truth while an event is live.
|
||||
|
||||
Since then: local (non-Authentik) Gemeinde Teamer accounts + invites
|
||||
(`teamer/`, `auth/team-login`), Gemeinde CRUD (`gemeinde/`), Gemeinde
|
||||
Verantwortliche self-registration with LT approval (`onboarding/`), JIT
|
||||
`User` provisioning on first Authentik login, LEITUNGSTEAM derived from the
|
||||
Authentik `groups` claim, and an email module (`mail/`, log-only by default,
|
||||
SMTP opt-in) that sends personal Teamer invites. First Prisma migration is
|
||||
in (`backend/prisma/migrations/`); the backend has been run end to end
|
||||
against a local PostgreSQL 16. `npm test` covers the assignment algorithm
|
||||
and the new auth/onboarding services (56 tests).
|
||||
|
||||
Phase 7 (Flutter client): `client/app/` is a single Flutter codebase with
|
||||
the **web** target enabled — guest / local-Teamer / invite login, the
|
||||
Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
|
||||
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
|
||||
result), file list, live WebSocket chat, FCM web-push registration, and the
|
||||
Leitungsteam admin screens: KCs, Gemeinden, onboarding approvals, full
|
||||
Workshop-Wahl administration (create/open/close, workshops, Force-Zuteilung,
|
||||
run assignment, CSV export), Teamer accounts + invites, LT file upload, plus
|
||||
the Verantwortlichen self-registration flow. `flutter build web` /
|
||||
`flutter test` pass; the backend serves the build at `/` (SPA fallback
|
||||
covers the OIDC redirect `/v1/auth/callback`).
|
||||
|
||||
Still to do: a live browser test of the OIDC round-trip; mobile/desktop
|
||||
targets. Going live needs external config — the Authentik redirect + a test
|
||||
account, Nextcloud/S3 credentials, SMTP, and the Firebase push secrets
|
||||
(`apiKey`/`appId`/VAPID key + a service-account JSON). See
|
||||
`backend/.env.example` and `client/app/web/index.html`.
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Postgres connection used by Prisma
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
||||
|
||||
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
|
||||
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
|
||||
|
||||
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
||||
GUEST_JWT_SECRET="change-me"
|
||||
|
||||
PORT=3000
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
*.log
|
||||
@@ -1,37 +0,0 @@
|
||||
# KC-App Backend
|
||||
|
||||
NestJS API for the KC-App platform (see repo root README + plan for
|
||||
architecture context).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Auth model
|
||||
|
||||
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
|
||||
provisioned in Authentik; this API acts as an OIDC **resource server**,
|
||||
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
|
||||
then resolving local `Membership` rows to determine role + KC/Gemeinde
|
||||
scope. Clients perform the actual Authorization Code + PKCE flow against
|
||||
Authentik directly.
|
||||
- Guests/Konfis get a temporary local account (first/last name required, no
|
||||
Authentik) created via `POST /auth/guest` with a KC invite code, returning
|
||||
a JWT signed with `GUEST_JWT_SECRET`.
|
||||
|
||||
## Modules implemented so far
|
||||
|
||||
- `prisma/` — shared `PrismaClient` provider.
|
||||
- `auth/` — Authentik resource-server strategy + guest invite-code login.
|
||||
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
||||
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||
Leitungsteam roles are global across all KCs).
|
||||
|
||||
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat
|
||||
realtime gateway, local/cloud sync engine.
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
-10215
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "KC-App backend (NestJS)",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-ws": "^10.4.15",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.17.9",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
|
||||
model Kc {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
inviteCode String @unique
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
gemeinden Gemeinde[]
|
||||
memberships Membership[]
|
||||
wahlen Wahl[]
|
||||
files File[]
|
||||
channels ChatChannel[]
|
||||
guests GuestAccount[]
|
||||
}
|
||||
|
||||
/// A local congregation/community participating in one Kc.
|
||||
model Gemeinde {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
kcId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
memberships Membership[]
|
||||
guests GuestAccount[]
|
||||
|
||||
@@unique([kcId, name])
|
||||
}
|
||||
|
||||
enum Role {
|
||||
LEITUNGSTEAM
|
||||
GEMEINDE_VERANTWORTLICHER
|
||||
GEMEINDE_TEAMER
|
||||
}
|
||||
|
||||
/// Authentik-backed user (team member with elevated rights).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
authentikSub String @unique
|
||||
email String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
}
|
||||
|
||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
|
||||
model Membership {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
kcId String
|
||||
gemeindeId String?
|
||||
role Role
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, kcId, gemeindeId])
|
||||
}
|
||||
|
||||
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
|
||||
model GuestAccount {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
gemeindeId String?
|
||||
firstName String
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
teilnehmer Teilnehmer[]
|
||||
}
|
||||
|
||||
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||
model Wahl {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
name String
|
||||
datumsSchluessel String
|
||||
teil String
|
||||
isOpen Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
workshops Workshop[]
|
||||
teilnehmer Teilnehmer[]
|
||||
}
|
||||
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
name String
|
||||
kapazitaet Int
|
||||
|
||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||
zuteilungen Zuteilung[]
|
||||
}
|
||||
|
||||
/// A participant's submitted choices for a Wahl.
|
||||
model Teilnehmer {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
guestAccountId String
|
||||
prioritaeten Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
||||
zuteilung Zuteilung?
|
||||
|
||||
@@unique([wahlId, guestAccountId])
|
||||
}
|
||||
|
||||
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
|
||||
model Zuteilung {
|
||||
id String @id @default(cuid())
|
||||
teilnehmerId String @unique
|
||||
workshopId String
|
||||
isForced Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum FileVisibility {
|
||||
ALLE
|
||||
ALLE_AUSSER_KONFIS
|
||||
NUR_LT
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
storageKey String
|
||||
filename String
|
||||
visibility FileVisibility
|
||||
uploadedById String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ChatChannelType {
|
||||
GEMEINDE_GRUPPE
|
||||
DIREKT
|
||||
LT_UEBERGREIFEND
|
||||
BROADCAST
|
||||
}
|
||||
|
||||
model ChatChannel {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
type ChatChannelType
|
||||
gemeindeId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
senderUserId String?
|
||||
senderGuestId String?
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
senderUser User? @relation(fields: [senderUserId], references: [id])
|
||||
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { CreateGuestDto } from './dto/create-guest.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly guestAuth: GuestAuthService) {}
|
||||
|
||||
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
|
||||
@Post('guest')
|
||||
createGuest(@Body() dto: CreateGuestDto) {
|
||||
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { AuthentikStrategy } from './authentik.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||
signOptions: { expiresIn: '12h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [GuestAuthService, AuthentikStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Request } from 'express';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
export interface AuthenticatedMembership {
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/// Shape attached to req.user by JwtStrategy after validating an access token.
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
authentikSub: string;
|
||||
email: string;
|
||||
memberships: AuthenticatedMembership[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import * as jwksRsa from 'jwks-rsa';
|
||||
import { Request } from 'express';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
|
||||
interface AuthentikJwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
/// Validates access tokens issued by Authentik (resource-server pattern):
|
||||
/// signature is checked against Authentik's JWKS, then the local Membership
|
||||
/// table decides what the user may do. Authentik itself is only the identity
|
||||
/// source, never asked for authorization here.
|
||||
@Injectable()
|
||||
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaClient,
|
||||
) {
|
||||
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||
super({
|
||||
jwtFromRequest: (req: Request) =>
|
||||
req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice('Bearer '.length)
|
||||
: null,
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||
jwksUri: `${issuerUrl}/jwks/`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
}),
|
||||
issuer: issuerUrl,
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: payload.sub },
|
||||
include: { memberships: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not provisioned locally yet');
|
||||
}
|
||||
return {
|
||||
userId: user.id,
|
||||
authentikSub: user.authentikSub,
|
||||
email: user.email,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
kcId: m.kcId,
|
||||
gemeindeId: m.gemeindeId,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateGuestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName!: string;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
|
||||
export interface GuestJwtPayload {
|
||||
guestId: string;
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
}
|
||||
|
||||
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
|
||||
/// created via a KC invite code, and scoped to that single KC.
|
||||
@Injectable()
|
||||
export class GuestAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async createGuest(
|
||||
inviteCode: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||
if (!kc || !kc.isActive) {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
|
||||
const guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName, lastName },
|
||||
});
|
||||
|
||||
const payload: GuestJwtPayload = {
|
||||
guestId: guest.id,
|
||||
kcId: kc.id,
|
||||
gemeindeId: guest.gemeindeId,
|
||||
};
|
||||
return { accessToken: await this.jwt.signAsync(payload) };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/// Re-exported from the Prisma client so guards and strategies share one
|
||||
/// enum type with the database schema. Guests are not part of this enum
|
||||
/// since they authenticate separately and never hold elevated rights.
|
||||
export { Role } from '@prisma/client';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from './role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from './role.enum';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
|
||||
/// Checks the caller holds one of the required roles, scoped to the KC in the
|
||||
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
|
||||
/// memberships are global and satisfy any KC scope.
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
|
||||
const kcId = request.params?.kcId ?? request.body?.kcId;
|
||||
const hasRole = user.memberships.some((membership) => {
|
||||
if (!requiredRoles.includes(membership.role)) {
|
||||
return false;
|
||||
}
|
||||
if (membership.role === Role.LEITUNGSTEAM) {
|
||||
return true;
|
||||
}
|
||||
return kcId ? membership.kcId === kcId : true;
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient role for this KC');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateKcDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { KcService } from './kc.service';
|
||||
import { CreateKcDto } from './dto/create-kc.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
@Controller('kc')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
export class KcController {
|
||||
constructor(private readonly kc: KcService) {}
|
||||
|
||||
/// Only the Leitungsteam may create new KC events.
|
||||
@Post()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
create(@Body() dto: CreateKcDto) {
|
||||
return this.kc.createKc(dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
list() {
|
||||
return this.kc.listKcs();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KcService } from './kc.service';
|
||||
import { KcController } from './kc.controller';
|
||||
|
||||
@Module({
|
||||
providers: [KcService],
|
||||
controllers: [KcController],
|
||||
})
|
||||
export class KcModule {}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
|
||||
@Injectable()
|
||||
export class KcService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
createKc(name: string) {
|
||||
return this.prisma.kc.create({
|
||||
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||
});
|
||||
}
|
||||
|
||||
listKcs() {
|
||||
return this.prisma.kc.findMany();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors();
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/// Shared Prisma connection; injected wherever DB access is needed.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PrismaClient,
|
||||
useFactory: () => new PrismaClient(),
|
||||
},
|
||||
],
|
||||
exports: [PrismaClient],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
export { PrismaClient };
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
||||
# DevTools options
|
||||
devtools_options.yaml
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "e8113bf45620cbeb8aff64947ee4c93e16adb4cf"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
|
||||
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
|
||||
- platform: web
|
||||
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
|
||||
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,96 @@
|
||||
# KC-App client (Flutter)
|
||||
|
||||
Single Flutter codebase for the KC-App platform. **Web** is the only target
|
||||
enabled so far (`flutter config --enable-web`); Android/iOS/desktop can be
|
||||
added later with `flutter create --platforms=...` in this directory — the
|
||||
`lib/` code is platform-agnostic.
|
||||
|
||||
## Run
|
||||
|
||||
The Authentik redirect URI is `http://localhost:3000/v1/auth/callback`, so
|
||||
the app must be reached on `:3000` — i.e. served by the backend, not `flutter
|
||||
run`'s own dev server. Build it and let NestJS serve it:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build web # backend serves client/app/build/web at /
|
||||
# then run the backend (npm run start:dev in ../../backend) and open :3000
|
||||
```
|
||||
|
||||
For pure UI work without the OIDC flow, `flutter run -d chrome
|
||||
--dart-define=API_BASE=http://localhost:3000/api` still works (guest / local
|
||||
Teamer login only).
|
||||
|
||||
### Dart-defines
|
||||
|
||||
| define | default |
|
||||
|---|---|
|
||||
| `API_BASE` | `http://localhost:3000/api` |
|
||||
| `OIDC_ISSUER` | `https://sso.konfi-castle.com/application/o/konfi-castle-app/` |
|
||||
| `OIDC_CLIENT_ID` | the konfi-castle public client id |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:3000/v1/auth/callback` |
|
||||
|
||||
## What's implemented
|
||||
|
||||
- **Login** (`lib/screens/login_screen.dart`) — three tabs:
|
||||
- *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`.
|
||||
- *Leitungsteam / Verantwortliche*: "Mit Konfi-Castle-ID anmelden" starts
|
||||
the Authentik **Authorization Code + PKCE** flow (`lib/oidc.dart`);
|
||||
below it, the local Gemeinde-Teamer password form
|
||||
(`POST /auth/team-login`).
|
||||
- *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`.
|
||||
- OIDC: discovery + S256 challenge, `?code=` handled on bootstrap, access +
|
||||
refresh token persisted (`shared_preferences` / localStorage), expired
|
||||
access token refreshed on restart. `GET /auth/me` resolves the role.
|
||||
- **Home** (`lib/screens/home_screen.dart`) — identity card + navigation.
|
||||
- **Verwaltung** (`lib/screens/admin_screen.dart`, Leitungsteam only) —
|
||||
list/create KCs; per KC:
|
||||
- Gemeinden (list/create); each opens **Teamer-Verwaltung**
|
||||
(`teamer_admin_screen.dart`): local Teamer accounts + group-link / email
|
||||
invites.
|
||||
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, open/close
|
||||
them, add workshops, list participants + Force-Zuteilung, run the
|
||||
assignment, view the result table, export the CSV (browser download).
|
||||
- **Dateien** (`files_admin_screen.dart`): upload with a visibility tier
|
||||
(native `<input type=file>`), list. Needs a configured Nextcloud/S3 on
|
||||
the backend or the upload returns 500.
|
||||
- pending Verantwortlichen self-registrations (approve / reject).
|
||||
- **Als Verantwortliche/r registrieren**
|
||||
(`verantwortliche_register_screen.dart`) — shown on the home screen to a
|
||||
logged-in Authentik user without a membership: enter a KC invite code,
|
||||
pick a Gemeinde, submit; a Leitungsteam member then approves.
|
||||
- **Push (web)** — `web/index.html` loads the Firebase compat SDK and
|
||||
`web/firebase-messaging-sw.js` handles background messages. After login
|
||||
`AppState` calls `window.kcGetPushToken()` and registers the token
|
||||
(`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are
|
||||
filled into both files (see the `REPLACE_ME` placeholders).
|
||||
- **Nutzungsanalysen (web)** — `web/index.html` also initialises Google
|
||||
Analytics for Firebase (`firebase.analytics()`) on every page load,
|
||||
independent of login/push. Automatically logs `page_view` /
|
||||
`session_start` / `first_visit`; visible in the Firebase Console under
|
||||
**Analytics** (data can take a few hours to first appear, and won't show
|
||||
on `localhost` — Analytics filters out non-public hostnames by default).
|
||||
Screen-level events inside the Flutter SPA aren't tracked without further
|
||||
instrumentation, but overall reach/users/sessions are.
|
||||
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
|
||||
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
|
||||
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results` —
|
||||
PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
|
||||
- **Dateien** (`lib/screens/files_screen.dart`) — `GET /files/:kcId`,
|
||||
filtered server-side by the caller's visibility tier.
|
||||
- **Chat** (`lib/screens/chat_screen.dart` + `lib/chat_socket.dart`) —
|
||||
channel list, REST history, then a live `/chat` WebSocket connection
|
||||
(`chat:join` / `chat:send` / `chat:message`) with a compose bar.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `lib/api.dart` — `Api` (thin REST wrapper + models) and `AppState`
|
||||
(`ChangeNotifier`: session, login/logout, token persistence).
|
||||
- `lib/oidc.dart` — Authentik PKCE flow. Browser-only bits (sessionStorage,
|
||||
redirect, `window.location`) sit behind a conditional import
|
||||
(`browser.dart` → `browser_web.dart` / `browser_stub.dart`) so
|
||||
`flutter test` compiles on the Dart VM.
|
||||
- `lib/chat_socket.dart` — `/chat` WebSocket wrapper.
|
||||
- `lib/main.dart` — `AppScope` (an `InheritedNotifier<AppState>`) exposes
|
||||
`AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party
|
||||
state-management package.
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- web/**
|
||||
@@ -0,0 +1,836 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'browser.dart' as browser;
|
||||
import 'oidc.dart';
|
||||
|
||||
/// Backend base URL. Override at build/run time with
|
||||
/// `--dart-define=API_BASE=https://...`.
|
||||
const String kApiBase = String.fromEnvironment(
|
||||
'API_BASE',
|
||||
defaultValue: 'http://localhost:3000/api',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.statusCode, this.message);
|
||||
final int statusCode;
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ApiException($statusCode): $message';
|
||||
}
|
||||
|
||||
enum SessionKind { guest, user }
|
||||
|
||||
/// Whatever `GET /auth/me` told us about the current token.
|
||||
class Identity {
|
||||
Identity({
|
||||
required this.kind,
|
||||
this.guestId,
|
||||
this.userId,
|
||||
this.email,
|
||||
this.kcId,
|
||||
this.gemeindeId,
|
||||
this.isLeitungsteam = false,
|
||||
this.memberships = const [],
|
||||
});
|
||||
|
||||
final SessionKind kind;
|
||||
final String? guestId;
|
||||
final String? userId;
|
||||
final String? email;
|
||||
final String? kcId;
|
||||
final String? gemeindeId;
|
||||
final bool isLeitungsteam;
|
||||
final List<Membership> memberships;
|
||||
|
||||
factory Identity.fromJson(Map<String, dynamic> j) {
|
||||
if (j['kind'] == 'guest') {
|
||||
return Identity(
|
||||
kind: SessionKind.guest,
|
||||
guestId: j['guestId'] as String?,
|
||||
kcId: j['kcId'] as String?,
|
||||
gemeindeId: j['gemeindeId'] as String?,
|
||||
);
|
||||
}
|
||||
final ms = (j['memberships'] as List<dynamic>? ?? [])
|
||||
.map((m) => Membership.fromJson(m as Map<String, dynamic>))
|
||||
.toList();
|
||||
return Identity(
|
||||
kind: SessionKind.user,
|
||||
userId: j['userId'] as String?,
|
||||
email: j['email'] as String?,
|
||||
isLeitungsteam: j['isLeitungsteam'] as bool? ?? false,
|
||||
memberships: ms,
|
||||
kcId: ms.isNotEmpty ? ms.first.kcId : null,
|
||||
gemeindeId: ms.isNotEmpty ? ms.first.gemeindeId : null,
|
||||
);
|
||||
}
|
||||
|
||||
String get roleLabel {
|
||||
if (kind == SessionKind.guest) return 'Konfi / Gast';
|
||||
if (isLeitungsteam) return 'Leitungsteam';
|
||||
if (memberships.any((m) => m.role == 'GEMEINDE_VERANTWORTLICHER')) {
|
||||
return 'Gemeinde Verantwortliche/r';
|
||||
}
|
||||
if (memberships.any((m) => m.role == 'GEMEINDE_TEAMER')) {
|
||||
return 'Gemeinde Teamer:in';
|
||||
}
|
||||
return 'Angemeldet (ohne Rolle)';
|
||||
}
|
||||
}
|
||||
|
||||
class Membership {
|
||||
Membership({required this.kcId, this.gemeindeId, required this.role});
|
||||
final String kcId;
|
||||
final String? gemeindeId;
|
||||
final String role;
|
||||
factory Membership.fromJson(Map<String, dynamic> j) => Membership(
|
||||
kcId: j['kcId'] as String,
|
||||
gemeindeId: j['gemeindeId'] as String?,
|
||||
role: j['role'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
class Kc {
|
||||
Kc({required this.id, required this.name, required this.inviteCode, required this.isActive});
|
||||
final String id;
|
||||
final String name;
|
||||
final String inviteCode;
|
||||
final bool isActive;
|
||||
factory Kc.fromJson(Map<String, dynamic> j) => Kc(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
inviteCode: j['inviteCode'] as String? ?? '',
|
||||
isActive: j['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class Gemeinde {
|
||||
Gemeinde({required this.id, required this.name, required this.kcId});
|
||||
final String id;
|
||||
final String name;
|
||||
final String kcId;
|
||||
factory Gemeinde.fromJson(Map<String, dynamic> j) => Gemeinde(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
kcId: j['kcId'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class OnboardingRequest {
|
||||
OnboardingRequest({
|
||||
required this.id,
|
||||
required this.userName,
|
||||
required this.userEmail,
|
||||
required this.gemeindeName,
|
||||
});
|
||||
final String id;
|
||||
final String userName;
|
||||
final String userEmail;
|
||||
final String gemeindeName;
|
||||
factory OnboardingRequest.fromJson(Map<String, dynamic> j) {
|
||||
final u = j['user'] as Map<String, dynamic>? ?? const {};
|
||||
final g = j['gemeinde'] as Map<String, dynamic>? ?? const {};
|
||||
return OnboardingRequest(
|
||||
id: j['id'] as String,
|
||||
userName: [u['firstName'], u['lastName']].whereType<String>().join(' ').trim(),
|
||||
userEmail: u['email'] as String? ?? '',
|
||||
gemeindeName: g['name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WahlAdmin {
|
||||
WahlAdmin({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.datumsSchluessel,
|
||||
required this.teil,
|
||||
required this.isOpen,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final String datumsSchluessel;
|
||||
final String teil;
|
||||
final bool isOpen;
|
||||
factory WahlAdmin.fromJson(Map<String, dynamic> j) => WahlAdmin(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
datumsSchluessel: j['datumsSchluessel'] as String? ?? '',
|
||||
teil: j['teil'] as String? ?? '',
|
||||
isOpen: j['isOpen'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class WorkshopAdmin {
|
||||
WorkshopAdmin({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.kapazitaet,
|
||||
required this.minTeilnehmer,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final int kapazitaet;
|
||||
final int minTeilnehmer;
|
||||
factory WorkshopAdmin.fromJson(Map<String, dynamic> j) => WorkshopAdmin(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
kapazitaet: (j['kapazitaet'] as num).toInt(),
|
||||
minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class TeilnehmerRow {
|
||||
TeilnehmerRow({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.prioritaeten,
|
||||
required this.forcedWorkshopId,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final List<String> prioritaeten;
|
||||
final String? forcedWorkshopId;
|
||||
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String? ?? '',
|
||||
prioritaeten:
|
||||
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
|
||||
forcedWorkshopId: j['forcedWorkshopId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class ZuteilungRow {
|
||||
ZuteilungRow({
|
||||
required this.name,
|
||||
required this.workshopName,
|
||||
required this.wunschRang,
|
||||
required this.isForced,
|
||||
});
|
||||
final String name;
|
||||
final String? workshopName;
|
||||
final int wunschRang;
|
||||
final bool isForced;
|
||||
factory ZuteilungRow.fromJson(Map<String, dynamic> j) {
|
||||
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
|
||||
as Map<String, dynamic>? ??
|
||||
const {};
|
||||
return ZuteilungRow(
|
||||
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
|
||||
workshopName: (j['workshop'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1,
|
||||
isForced: j['isForced'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TeamerAccount {
|
||||
TeamerAccount({required this.id, required this.email, required this.name});
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
factory TeamerAccount.fromJson(Map<String, dynamic> j) => TeamerAccount(
|
||||
id: j['id'] as String,
|
||||
email: j['email'] as String? ?? '',
|
||||
name: [j['firstName'], j['lastName']].whereType<String>().join(' ').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
class TeamerInvite {
|
||||
TeamerInvite({
|
||||
required this.id,
|
||||
required this.token,
|
||||
required this.email,
|
||||
required this.usedCount,
|
||||
required this.maxUses,
|
||||
required this.revoked,
|
||||
});
|
||||
final String id;
|
||||
final String token;
|
||||
final String? email;
|
||||
final int usedCount;
|
||||
final int? maxUses;
|
||||
final bool revoked;
|
||||
factory TeamerInvite.fromJson(Map<String, dynamic> j) => TeamerInvite(
|
||||
id: j['id'] as String,
|
||||
token: j['token'] as String,
|
||||
email: j['email'] as String?,
|
||||
usedCount: (j['usedCount'] as num?)?.toInt() ?? 0,
|
||||
maxUses: (j['maxUses'] as num?)?.toInt(),
|
||||
revoked: j['revokedAt'] != null,
|
||||
);
|
||||
}
|
||||
|
||||
class Workshop {
|
||||
Workshop({required this.id, required this.name, required this.kapazitaet});
|
||||
final String id;
|
||||
final String name;
|
||||
final int kapazitaet;
|
||||
factory Workshop.fromJson(Map<String, dynamic> j) => Workshop(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
kapazitaet: (j['kapazitaet'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
class Wahl {
|
||||
Wahl({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.datumsSchluessel,
|
||||
required this.teil,
|
||||
required this.workshops,
|
||||
required this.meinePrioritaeten,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final String datumsSchluessel;
|
||||
final String teil;
|
||||
final List<Workshop> workshops;
|
||||
final List<String>? meinePrioritaeten;
|
||||
|
||||
factory Wahl.fromJson(Map<String, dynamic> j) => Wahl(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
datumsSchluessel: j['datumsSchluessel'] as String,
|
||||
teil: j['teil'] as String,
|
||||
workshops: (j['workshops'] as List<dynamic>)
|
||||
.map((w) => Workshop.fromJson(w as Map<String, dynamic>))
|
||||
.toList(),
|
||||
meinePrioritaeten: (j['meinePrioritaeten'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class GuestOverview {
|
||||
GuestOverview({required this.kcName, required this.wahlen});
|
||||
final String kcName;
|
||||
final List<Wahl> wahlen;
|
||||
factory GuestOverview.fromJson(Map<String, dynamic> j) => GuestOverview(
|
||||
kcName: (j['kc'] as Map<String, dynamic>?)?['name'] as String? ?? '',
|
||||
wahlen: (j['wahlen'] as List<dynamic>)
|
||||
.map((w) => Wahl.fromJson(w as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
enum WahlResultStatus { pending, assigned, unassigned }
|
||||
|
||||
class WahlResult {
|
||||
WahlResult({
|
||||
required this.wahlName,
|
||||
required this.datumsSchluessel,
|
||||
required this.teil,
|
||||
required this.status,
|
||||
required this.workshopName,
|
||||
required this.wunschRang,
|
||||
required this.isForced,
|
||||
});
|
||||
final String wahlName;
|
||||
final String datumsSchluessel;
|
||||
final String teil;
|
||||
final WahlResultStatus status;
|
||||
final String? workshopName;
|
||||
final int? wunschRang;
|
||||
final bool isForced;
|
||||
|
||||
factory WahlResult.fromJson(Map<String, dynamic> j) {
|
||||
final wahl = j['wahl'] as Map<String, dynamic>;
|
||||
return WahlResult(
|
||||
wahlName: wahl['name'] as String,
|
||||
datumsSchluessel: wahl['datumsSchluessel'] as String,
|
||||
teil: wahl['teil'] as String,
|
||||
status: switch (j['status'] as String?) {
|
||||
'ASSIGNED' => WahlResultStatus.assigned,
|
||||
'UNASSIGNED' => WahlResultStatus.unassigned,
|
||||
_ => WahlResultStatus.pending,
|
||||
},
|
||||
workshopName: j['workshopName'] as String?,
|
||||
wunschRang: (j['wunschRang'] as num?)?.toInt(),
|
||||
isForced: j['isForced'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileEntry {
|
||||
FileEntry({required this.id, required this.filename, required this.visibility});
|
||||
final String id;
|
||||
final String filename;
|
||||
final String visibility;
|
||||
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
|
||||
id: j['id'] as String,
|
||||
filename: j['filename'] as String,
|
||||
visibility: j['visibility'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class ChatChannel {
|
||||
ChatChannel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.name,
|
||||
this.gemeindeId,
|
||||
this.createdByUserId,
|
||||
});
|
||||
final String id;
|
||||
final String type;
|
||||
final String? name;
|
||||
final String? gemeindeId;
|
||||
final String? createdByUserId;
|
||||
|
||||
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
|
||||
id: j['id'] as String,
|
||||
type: j['type'] as String? ?? '',
|
||||
name: j['name'] as String?,
|
||||
gemeindeId: j['gemeindeId'] as String?,
|
||||
createdByUserId: j['createdByUserId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class ChatMessage {
|
||||
ChatMessage({
|
||||
this.id,
|
||||
this.channelId,
|
||||
this.senderUserId,
|
||||
this.senderGuestId,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
});
|
||||
final String? id;
|
||||
final String? channelId;
|
||||
final String? senderUserId;
|
||||
final String? senderGuestId;
|
||||
final String body;
|
||||
final String createdAt;
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
|
||||
id: j['id'] as String?,
|
||||
channelId: j['channelId'] as String?,
|
||||
senderUserId: j['senderUserId'] as String?,
|
||||
senderGuestId: j['senderGuestId'] as String?,
|
||||
body: j['body'] as String? ?? '',
|
||||
createdAt: j['createdAt'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Thin REST wrapper. Holds the bearer token for the current session.
|
||||
class Api {
|
||||
Api(this._client);
|
||||
final http.Client _client;
|
||||
String? token;
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
Future<dynamic> _get(String path) async {
|
||||
final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers);
|
||||
return _decode(res);
|
||||
}
|
||||
|
||||
Future<dynamic> _post(String path, Object? body) async {
|
||||
final res = await _client.post(
|
||||
Uri.parse('$kApiBase$path'),
|
||||
headers: _headers,
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
);
|
||||
return _decode(res);
|
||||
}
|
||||
|
||||
Future<dynamic> _patch(String path, Object? body) async {
|
||||
final res = await _client.patch(
|
||||
Uri.parse('$kApiBase$path'),
|
||||
headers: _headers,
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
);
|
||||
return _decode(res);
|
||||
}
|
||||
|
||||
dynamic _decode(http.Response res) {
|
||||
final text = res.body.isEmpty ? '{}' : res.body;
|
||||
dynamic parsed;
|
||||
try {
|
||||
parsed = jsonDecode(text);
|
||||
} catch (_) {
|
||||
parsed = text;
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) return parsed;
|
||||
final msg = parsed is Map && parsed['message'] != null
|
||||
? (parsed['message'] is List
|
||||
? (parsed['message'] as List).join(', ')
|
||||
: parsed['message'].toString())
|
||||
: 'HTTP ${res.statusCode}';
|
||||
throw ApiException(res.statusCode, msg);
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
Future<String> guestLogin(String inviteCode, String firstName, String lastName) async {
|
||||
final j = await _post('/auth/guest', {
|
||||
'inviteCode': inviteCode,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
});
|
||||
return j['accessToken'] as String;
|
||||
}
|
||||
|
||||
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
|
||||
/// email (legacy/personal accounts) — pass exactly one of the two.
|
||||
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
|
||||
final j = await _post('/auth/team-login', {
|
||||
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
'password': password,
|
||||
});
|
||||
return j['accessToken'] as String;
|
||||
}
|
||||
|
||||
Future<String> redeemTeamerInvite({
|
||||
required String inviteToken,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String password,
|
||||
String? email,
|
||||
}) async {
|
||||
final j = await _post('/auth/teamer/register', {
|
||||
'token': inviteToken,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'password': password,
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
});
|
||||
return j['accessToken'] as String;
|
||||
}
|
||||
|
||||
Future<Identity> me() async =>
|
||||
Identity.fromJson(await _get('/auth/me') as Map<String, dynamic>);
|
||||
|
||||
// --- guest Wahl ---
|
||||
Future<GuestOverview> guestWahlOverview() async =>
|
||||
GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map<String, dynamic>);
|
||||
|
||||
Future<void> submitPrioritaeten(String wahlId, List<String> workshopIds) async {
|
||||
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
|
||||
}
|
||||
|
||||
Future<List<WahlResult>> guestWahlResults() async {
|
||||
final list = await _get('/wahl/guest/results') as List<dynamic>;
|
||||
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// --- files ---
|
||||
Future<List<FileEntry>> files(String kcId) async {
|
||||
final list = await _get('/files/$kcId') as List<dynamic>;
|
||||
return list.map((e) => FileEntry.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
|
||||
|
||||
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
|
||||
/// `/api` prefix) and authenticates via a `?token=` query param.
|
||||
Uri chatWsUri() {
|
||||
final base = Uri.parse(kApiBase);
|
||||
return Uri(
|
||||
scheme: base.scheme == 'https' ? 'wss' : 'ws',
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
path: '/chat',
|
||||
queryParameters: {'token': token ?? ''},
|
||||
);
|
||||
}
|
||||
|
||||
// --- LT admin ---
|
||||
Future<List<Kc>> kcs() async {
|
||||
final list = await _get('/kc') as List<dynamic>;
|
||||
return list.map((e) => Kc.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Kc> createKc(String name) async =>
|
||||
Kc.fromJson(await _post('/kc', {'name': name}) as Map<String, dynamic>);
|
||||
|
||||
Future<List<Gemeinde>> gemeinden(String kcId) async {
|
||||
final list = await _get('/gemeinde?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => Gemeinde.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Gemeinde> createGemeinde(String kcId, String name) async => Gemeinde.fromJson(
|
||||
await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map<String, dynamic>);
|
||||
|
||||
Future<List<OnboardingRequest>> onboardingRequests(String kcId) async {
|
||||
final list = await _get('/onboarding/requests?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => OnboardingRequest.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
||||
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
|
||||
|
||||
// --- LT Wahl administration ---
|
||||
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
|
||||
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createWahl(
|
||||
String kcId,
|
||||
String name,
|
||||
String datumsSchluessel,
|
||||
String teil,
|
||||
) =>
|
||||
_post('/wahl', {
|
||||
'kcId': kcId,
|
||||
'name': name,
|
||||
'datumsSchluessel': datumsSchluessel,
|
||||
'teil': teil,
|
||||
});
|
||||
|
||||
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
|
||||
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
|
||||
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createWorkshop(
|
||||
String wahlId,
|
||||
String name,
|
||||
int kapazitaet,
|
||||
int minTeilnehmer,
|
||||
) =>
|
||||
_post('/wahl/$wahlId/workshops', {
|
||||
'name': name,
|
||||
'kapazitaet': kapazitaet,
|
||||
'minTeilnehmer': minTeilnehmer,
|
||||
});
|
||||
|
||||
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
|
||||
|
||||
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
|
||||
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
|
||||
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
|
||||
_patch('/wahl/$wahlId', {'isOpen': isOpen});
|
||||
|
||||
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
|
||||
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
|
||||
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
|
||||
_post('/wahl/$wahlId/force-zuteilung', {
|
||||
'teilnehmerId': teilnehmerId,
|
||||
'workshopId': workshopId,
|
||||
});
|
||||
|
||||
Future<String> zuteilungCsv(String wahlId) async {
|
||||
final res = await _get('/wahl/$wahlId/zuteilung/csv');
|
||||
return res is String ? res : res.toString();
|
||||
}
|
||||
|
||||
Future<void> uploadFile(
|
||||
String kcId,
|
||||
String filename,
|
||||
List<int> bytes,
|
||||
String visibility,
|
||||
) async {
|
||||
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
|
||||
..fields['visibility'] = visibility
|
||||
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
|
||||
if (token != null) req.headers['Authorization'] = 'Bearer $token';
|
||||
final streamed = await req.send();
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
_decode(res); // throws ApiException with the server message
|
||||
}
|
||||
}
|
||||
|
||||
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
|
||||
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
||||
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
||||
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createTeamer(
|
||||
String gemeindeId, {
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
required String password,
|
||||
}) =>
|
||||
_post('/gemeinde/$gemeindeId/teamer', {
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
|
||||
Future<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
|
||||
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
|
||||
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<TeamerInvite> createTeamerInvite(
|
||||
String gemeindeId, {
|
||||
String? email,
|
||||
int? maxUses,
|
||||
int? expiresInHours,
|
||||
}) async =>
|
||||
TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', {
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
'maxUses': ?maxUses,
|
||||
'expiresInHours': ?expiresInHours,
|
||||
}) as Map<String, dynamic>);
|
||||
|
||||
// --- Verantwortlichen self-registration ---
|
||||
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
|
||||
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> registerVerantwortliche(
|
||||
String inviteCode,
|
||||
String gemeindeId,
|
||||
) async =>
|
||||
await _post('/onboarding/verantwortliche', {
|
||||
'inviteCode': inviteCode,
|
||||
'gemeindeId': gemeindeId,
|
||||
}) as Map<String, dynamic>;
|
||||
|
||||
// --- push ---
|
||||
Future<void> registerDevice(String token, {String platform = 'web'}) =>
|
||||
_post('/push/register', {'token': token, 'platform': platform});
|
||||
|
||||
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
||||
Future<List<ChatChannel>> channels(String kcId) async {
|
||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||
return list.map((e) => ChatChannel.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<ChatMessage>> messages(String channelId) async {
|
||||
final list = await _get('/chat/channels/$channelId/messages') as List<dynamic>;
|
||||
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide session + auth actions. Persists the token in shared_preferences
|
||||
/// (localStorage on web).
|
||||
class AppState extends ChangeNotifier {
|
||||
AppState(this._api, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client());
|
||||
final Api _api;
|
||||
final OidcClient _oidc;
|
||||
|
||||
static const _tokenKey = 'kc_token';
|
||||
static const _refreshKey = 'kc_refresh';
|
||||
|
||||
Identity? _identity;
|
||||
Identity? get identity => _identity;
|
||||
bool _loading = true;
|
||||
bool get loading => _loading;
|
||||
bool get isLoggedIn => _identity != null;
|
||||
String? _authError;
|
||||
String? get authError => _authError;
|
||||
|
||||
Api get api => _api;
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// 1. Are we landing on the OIDC redirect (?code=…)?
|
||||
try {
|
||||
final tokens = await _oidc.completeIfCallback();
|
||||
if (tokens != null) {
|
||||
await _establish(tokens.accessToken, refreshToken: tokens.refreshToken);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_authError = '$e';
|
||||
}
|
||||
|
||||
// 2. Restore a stored session, refreshing an expired Authentik token.
|
||||
final saved = prefs.getString(_tokenKey);
|
||||
if (saved != null) {
|
||||
_api.token = saved;
|
||||
try {
|
||||
_identity = await _api.me();
|
||||
} catch (_) {
|
||||
final refresh = prefs.getString(_refreshKey);
|
||||
if (refresh != null) {
|
||||
try {
|
||||
final t = await _oidc.refresh(refresh);
|
||||
await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh);
|
||||
} catch (_) {
|
||||
await _clear(prefs);
|
||||
}
|
||||
} else {
|
||||
await _clear(prefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> beginOidcLogin() => _oidc.beginLogin();
|
||||
|
||||
Future<void> _establish(String token, {String? refreshToken}) async {
|
||||
_api.token = token;
|
||||
_identity = await _api.me();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, token);
|
||||
if (refreshToken != null) {
|
||||
await prefs.setString(_refreshKey, refreshToken);
|
||||
}
|
||||
_authError = null;
|
||||
notifyListeners();
|
||||
_registerForPush(); // best-effort, fire and forget
|
||||
}
|
||||
|
||||
Future<void> _registerForPush() async {
|
||||
try {
|
||||
final pushToken = await browser.getPushToken();
|
||||
if (pushToken != null) await _api.registerDevice(pushToken);
|
||||
} catch (_) {
|
||||
// push is optional
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clear(SharedPreferences prefs) async {
|
||||
_api.token = null;
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_refreshKey);
|
||||
}
|
||||
|
||||
Future<void> guestLogin(String code, String first, String last) =>
|
||||
_api.guestLogin(code, first, last).then(_establish);
|
||||
|
||||
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
|
||||
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
|
||||
|
||||
Future<void> redeemInvite({
|
||||
required String token,
|
||||
required String first,
|
||||
required String last,
|
||||
required String password,
|
||||
String? email,
|
||||
}) =>
|
||||
_api
|
||||
.redeemTeamerInvite(
|
||||
inviteToken: token,
|
||||
firstName: first,
|
||||
lastName: last,
|
||||
password: password,
|
||||
email: email,
|
||||
)
|
||||
.then(_establish);
|
||||
|
||||
Future<void> logout() async {
|
||||
_identity = null;
|
||||
_authError = null;
|
||||
await _clear(await SharedPreferences.getInstance());
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Picks the real browser implementation on web, a throwing stub elsewhere
|
||||
// (so `flutter test` on the Dart VM still compiles).
|
||||
export 'browser_stub.dart' if (dart.library.js_interop) 'browser_web.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
// Non-web fallback: the OIDC redirect flow only runs in a browser.
|
||||
const _msg = 'Browser-only: OIDC login is not available on this platform.';
|
||||
|
||||
void setSession(String key, String value) => throw UnsupportedError(_msg);
|
||||
String? getSession(String key) => throw UnsupportedError(_msg);
|
||||
void removeSession(String key) => throw UnsupportedError(_msg);
|
||||
Never redirect(String url) => throw UnsupportedError(_msg);
|
||||
Map<String, String> currentQueryParameters() => const {};
|
||||
void clearQuery() {}
|
||||
|
||||
Future<({String name, List<int> bytes})?> pickFile() async =>
|
||||
throw UnsupportedError(_msg);
|
||||
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
|
||||
throw UnsupportedError(_msg);
|
||||
|
||||
/// No push on non-web platforms in this build.
|
||||
Future<String?> getPushToken() async => null;
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
/// Provided by the inline Firebase bootstrap in web/index.html. Returns an FCM
|
||||
/// registration token, or null if push isn't configured / permission denied.
|
||||
@JS('kcGetPushToken')
|
||||
external JSPromise<JSString?> _kcGetPushToken();
|
||||
|
||||
Future<String?> getPushToken() async {
|
||||
try {
|
||||
final result = await _kcGetPushToken().toDart;
|
||||
return result?.toDart;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
|
||||
/// closes), and the little bit of `window` access the OIDC redirect needs.
|
||||
|
||||
void setSession(String key, String value) =>
|
||||
web.window.sessionStorage.setItem(key, value);
|
||||
|
||||
String? getSession(String key) => web.window.sessionStorage.getItem(key);
|
||||
|
||||
void removeSession(String key) => web.window.sessionStorage.removeItem(key);
|
||||
|
||||
void redirect(String url) => web.window.location.assign(url);
|
||||
|
||||
Map<String, String> currentQueryParameters() =>
|
||||
Uri.parse(web.window.location.href).queryParameters;
|
||||
|
||||
/// Drop the OIDC callback path + `?code=…&state=…` from the address bar
|
||||
/// without reloading (back to the app root).
|
||||
void clearQuery() {
|
||||
web.window.history.replaceState(null, '', '/');
|
||||
}
|
||||
|
||||
/// Opens the OS file picker and reads the chosen file's bytes.
|
||||
Future<({String name, List<int> bytes})?> pickFile() {
|
||||
final completer = Completer<({String name, List<int> bytes})?>();
|
||||
final input = web.HTMLInputElement()..type = 'file';
|
||||
input.onchange = ((web.Event _) {
|
||||
final files = input.files;
|
||||
if (files == null || files.length == 0) {
|
||||
completer.complete(null);
|
||||
return;
|
||||
}
|
||||
final file = files.item(0)!;
|
||||
final reader = web.FileReader();
|
||||
reader.onload = ((web.Event _) {
|
||||
final buffer = (reader.result as JSArrayBuffer).toDart;
|
||||
completer.complete((name: file.name, bytes: buffer.asUint8List()));
|
||||
}).toJS;
|
||||
reader.onerror = ((web.Event _) => completer.complete(null)).toJS;
|
||||
reader.readAsArrayBuffer(file);
|
||||
}).toJS;
|
||||
input.click();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Triggers a browser download of an in-memory string (e.g. the CSV export).
|
||||
void downloadText(
|
||||
String filename,
|
||||
String content, {
|
||||
String mime = 'text/csv;charset=utf-8',
|
||||
}) {
|
||||
final blob = web.Blob([content.toJS].toJS, web.BlobPropertyBag(type: mime));
|
||||
final url = web.URL.createObjectURL(blob);
|
||||
web.HTMLAnchorElement()
|
||||
..href = url
|
||||
..download = filename
|
||||
..click();
|
||||
web.URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'api.dart';
|
||||
|
||||
/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter`
|
||||
/// expects `{"event": ..., "data": ...}` frames in both directions.
|
||||
class ChatSocket {
|
||||
ChatSocket(this._uri);
|
||||
final Uri _uri;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
final _messages = StreamController<ChatMessage>.broadcast();
|
||||
final _status = StreamController<String>.broadcast();
|
||||
|
||||
/// Incoming `chat:message` frames.
|
||||
Stream<ChatMessage> get messages => _messages.stream;
|
||||
|
||||
/// "connected" / "closed" / "error: ..." for a small status line.
|
||||
Stream<String> get status => _status.stream;
|
||||
|
||||
void connect(String channelId) {
|
||||
_channel = WebSocketChannel.connect(_uri);
|
||||
_channel!.stream.listen(
|
||||
(raw) {
|
||||
_status.add('connected');
|
||||
try {
|
||||
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
if (frame['event'] == 'chat:message') {
|
||||
_messages.add(ChatMessage.fromJson(frame['data'] as Map<String, dynamic>));
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore frames we don't model
|
||||
}
|
||||
},
|
||||
onError: (Object e) => _status.add('error: $e'),
|
||||
onDone: () => _status.add('closed'),
|
||||
);
|
||||
_send('chat:join', {'channelId': channelId});
|
||||
}
|
||||
|
||||
void sendMessage(String channelId, String body) {
|
||||
_send('chat:send', {'channelId': channelId, 'body': body});
|
||||
}
|
||||
|
||||
void _send(String event, Map<String, dynamic> data) {
|
||||
_channel?.sink.add(jsonEncode({'event': event, 'data': data}));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_channel?.sink.close();
|
||||
_messages.close();
|
||||
_status.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
void main() {
|
||||
final state = AppState(Api(http.Client()))..bootstrap();
|
||||
runApp(KcApp(state: state));
|
||||
}
|
||||
|
||||
/// Minimal InheritedNotifier so screens can read `AppState.of(context)` and
|
||||
/// rebuild on change — no third-party state management.
|
||||
class AppScope extends InheritedNotifier<AppState> {
|
||||
const AppScope({super.key, required AppState state, required super.child})
|
||||
: super(notifier: state);
|
||||
|
||||
static AppState of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope missing above this widget');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
|
||||
class KcApp extends StatelessWidget {
|
||||
const KcApp({super.key, required this.state});
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScope(
|
||||
state: state,
|
||||
child: MaterialApp(
|
||||
title: 'KC-App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildKcTheme(),
|
||||
home: const _AuthGate(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthGate extends StatelessWidget {
|
||||
const _AuthGate();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
return state.isLoggedIn ? const HomeScreen() : const LoginScreen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'browser.dart' as browser;
|
||||
|
||||
/// Authentik OIDC config. Overridable with --dart-define; defaults are the
|
||||
/// konfi-castle production values (a *public* client — PKCE, no secret).
|
||||
const kOidcIssuer = String.fromEnvironment(
|
||||
'OIDC_ISSUER',
|
||||
defaultValue: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/',
|
||||
);
|
||||
const kOidcClientId = String.fromEnvironment(
|
||||
'OIDC_CLIENT_ID',
|
||||
defaultValue: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9',
|
||||
);
|
||||
const kOidcRedirectUri = String.fromEnvironment(
|
||||
'OIDC_REDIRECT_URI',
|
||||
defaultValue: 'http://localhost:3000/v1/auth/callback',
|
||||
);
|
||||
|
||||
const _scope = 'openid profile email groups offline_access';
|
||||
const _verifierKey = 'oidc_verifier';
|
||||
const _stateKey = 'oidc_state';
|
||||
|
||||
class OidcTokens {
|
||||
OidcTokens({required this.accessToken, this.refreshToken, this.expiresIn});
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final int? expiresIn;
|
||||
|
||||
factory OidcTokens.fromJson(Map<String, dynamic> j) => OidcTokens(
|
||||
accessToken: j['access_token'] as String,
|
||||
refreshToken: j['refresh_token'] as String?,
|
||||
expiresIn: (j['expires_in'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
class OidcException implements Exception {
|
||||
OidcException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'OidcException: $message';
|
||||
}
|
||||
|
||||
/// Authorization-Code + PKCE against Authentik, for the browser. The backend
|
||||
/// only validates the resulting access token (resource-server pattern).
|
||||
class OidcClient {
|
||||
OidcClient(this._http);
|
||||
final http.Client _http;
|
||||
Map<String, dynamic>? _discovery;
|
||||
|
||||
Future<Map<String, dynamic>> _disc() async {
|
||||
if (_discovery != null) return _discovery!;
|
||||
final base = kOidcIssuer.endsWith('/') ? kOidcIssuer : '$kOidcIssuer/';
|
||||
final res = await _http.get(Uri.parse('$base.well-known/openid-configuration'));
|
||||
if (res.statusCode != 200) {
|
||||
throw OidcException('Discovery failed (${res.statusCode})');
|
||||
}
|
||||
return _discovery = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Kicks off the redirect to Authentik. Does not return (page navigates).
|
||||
Future<void> beginLogin() async {
|
||||
final d = await _disc();
|
||||
final verifier = _randomUrlToken(64);
|
||||
final state = _randomUrlToken(24);
|
||||
final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes)
|
||||
.replaceAll('=', '');
|
||||
browser.setSession(_verifierKey, verifier);
|
||||
browser.setSession(_stateKey, state);
|
||||
|
||||
final authUri = Uri.parse(d['authorization_endpoint'] as String).replace(
|
||||
queryParameters: {
|
||||
'response_type': 'code',
|
||||
'client_id': kOidcClientId,
|
||||
'redirect_uri': kOidcRedirectUri,
|
||||
'scope': _scope,
|
||||
'state': state,
|
||||
'code_challenge': challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
},
|
||||
);
|
||||
browser.redirect(authUri.toString());
|
||||
}
|
||||
|
||||
/// If the current URL carries `?code=…`, exchanges it for tokens and scrubs
|
||||
/// the query. Returns null when this isn't a callback load.
|
||||
Future<OidcTokens?> completeIfCallback() async {
|
||||
final params = browser.currentQueryParameters();
|
||||
final code = params['code'];
|
||||
if (code == null || code.isEmpty) {
|
||||
if (params['error'] != null) {
|
||||
browser.clearQuery();
|
||||
throw OidcException('Authentik: ${params['error_description'] ?? params['error']}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final expectedState = browser.getSession(_stateKey);
|
||||
final verifier = browser.getSession(_verifierKey);
|
||||
browser.removeSession(_stateKey);
|
||||
browser.removeSession(_verifierKey);
|
||||
browser.clearQuery();
|
||||
|
||||
if (verifier == null || params['state'] != expectedState) {
|
||||
throw OidcException('State mismatch — please retry the login.');
|
||||
}
|
||||
final d = await _disc();
|
||||
final res = await _http.post(
|
||||
Uri.parse(d['token_endpoint'] as String),
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': kOidcRedirectUri,
|
||||
'client_id': kOidcClientId,
|
||||
'code_verifier': verifier,
|
||||
},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw OidcException('Token exchange failed (${res.statusCode}): ${res.body}');
|
||||
}
|
||||
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<OidcTokens> refresh(String refreshToken) async {
|
||||
final d = await _disc();
|
||||
final res = await _http.post(
|
||||
Uri.parse(d['token_endpoint'] as String),
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'client_id': kOidcClientId,
|
||||
},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw OidcException('Refresh failed (${res.statusCode})');
|
||||
}
|
||||
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static String _randomUrlToken(int length) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final rnd = Random.secure();
|
||||
return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import 'files_admin_screen.dart';
|
||||
import 'teamer_admin_screen.dart';
|
||||
import 'ui.dart';
|
||||
import 'wahl_admin_screen.dart';
|
||||
|
||||
/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations.
|
||||
class AdminScreen extends StatefulWidget {
|
||||
const AdminScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminScreen> createState() => _AdminScreenState();
|
||||
}
|
||||
|
||||
class _AdminScreenState extends State<AdminScreen> {
|
||||
Future<List<Kc>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.kcs();
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _future = AppScope.of(context).api.kcs());
|
||||
|
||||
Future<void> _createKc() async {
|
||||
final api = AppScope.of(context).api;
|
||||
final name = await promptText(context, 'Neues KC', 'Name');
|
||||
if (name == null || name.isEmpty || !mounted) return;
|
||||
try {
|
||||
await api.createKc(name);
|
||||
if (mounted) _reload();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Verwaltung')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _createKc,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('KC'),
|
||||
),
|
||||
body: FutureBuilder<List<Kc>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snap.error}', textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
final kcs = snap.data!;
|
||||
if (kcs.isEmpty) {
|
||||
return const Center(child: Text('Noch keine KCs. Unten anlegen.'));
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final kc in kcs)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.festival),
|
||||
title: Text(kc.name),
|
||||
subtitle: Text('Code ${kc.inviteCode}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => KcDetailScreen(kc: kc)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KcDetailScreen extends StatefulWidget {
|
||||
const KcDetailScreen({super.key, required this.kc});
|
||||
final Kc kc;
|
||||
|
||||
@override
|
||||
State<KcDetailScreen> createState() => _KcDetailScreenState();
|
||||
}
|
||||
|
||||
class _KcDetailScreenState extends State<KcDetailScreen> {
|
||||
Future<List<Gemeinde>>? _gemeinden;
|
||||
Future<List<OnboardingRequest>>? _requests;
|
||||
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_gemeinden ??= _api.gemeinden(widget.kc.id);
|
||||
_requests ??= _api.onboardingRequests(widget.kc.id);
|
||||
}
|
||||
|
||||
void _reloadGemeinden() =>
|
||||
setState(() => _gemeinden = _api.gemeinden(widget.kc.id));
|
||||
void _reloadRequests() =>
|
||||
setState(() => _requests = _api.onboardingRequests(widget.kc.id));
|
||||
|
||||
Future<void> _addGemeinde() async {
|
||||
final api = _api;
|
||||
final name = await promptText(context, 'Neue Gemeinde', 'Name');
|
||||
if (name == null || name.isEmpty || !mounted) return;
|
||||
try {
|
||||
await api.createGemeinde(widget.kc.id, name);
|
||||
if (mounted) _reloadGemeinden();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.kc.name)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
title: const Text('Einladungscode'),
|
||||
subtitle: Text(widget.kc.inviteCode),
|
||||
trailing: const Icon(Icons.qr_code_2),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.how_to_vote),
|
||||
title: const Text('Workshop-Wahlen'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.folder_shared),
|
||||
title: const Text('Dateien'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => FilesAdminScreen(kcId: widget.kc.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionHeader('Gemeinden', action: TextButton.icon(
|
||||
onPressed: _addGemeinde,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Hinzufügen'),
|
||||
)),
|
||||
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
|
||||
const Divider(height: 40),
|
||||
Text('Offene Verantwortlichen-Anfragen',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_RequestList(
|
||||
future: _requests!,
|
||||
onAction: (id, approve) async {
|
||||
try {
|
||||
approve
|
||||
? await _api.approveOnboarding(id)
|
||||
: await _api.rejectOnboarding(id);
|
||||
_reloadRequests();
|
||||
} catch (e) {
|
||||
if (context.mounted) toast(context, '$e');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GemeindeList extends StatelessWidget {
|
||||
const _GemeindeList({required this.future, required this.onRetry});
|
||||
final Future<List<Gemeinde>> future;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Gemeinde>>(
|
||||
future: future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return TextButton(onPressed: onRetry, child: Text('Fehler: ${snap.error}'));
|
||||
}
|
||||
final gemeinden = snap.data!;
|
||||
if (gemeinden.isEmpty) return const Text('Noch keine Gemeinden.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final g in gemeinden)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.church),
|
||||
title: Text(g.name),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TeamerAdminScreen(gemeindeId: g.id, gemeindeName: g.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestList extends StatelessWidget {
|
||||
const _RequestList({required this.future, required this.onAction});
|
||||
final Future<List<OnboardingRequest>> future;
|
||||
final Future<void> Function(String id, bool approve) onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<OnboardingRequest>>(
|
||||
future: future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Text('Fehler: ${snap.error}');
|
||||
}
|
||||
final requests = snap.data!;
|
||||
if (requests.isEmpty) return const Text('Keine offenen Anfragen.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final r in requests)
|
||||
Card(
|
||||
child: ListTile(
|
||||
title: Text(r.userName.isEmpty ? r.userEmail : r.userName),
|
||||
subtitle: Text('${r.userEmail}\nGemeinde: ${r.gemeindeName}'),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Genehmigen',
|
||||
icon: const Icon(Icons.check, color: Colors.green),
|
||||
onPressed: () => onAction(r.id, true),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Ablehnen',
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
onPressed: () => onAction(r.id, false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../chat_socket.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// WhatsApp brand colors
|
||||
abstract final class WhatsAppColors {
|
||||
static const primary = Color(0xFF008069); // WhatsApp Header Green
|
||||
static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green
|
||||
static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green
|
||||
static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG
|
||||
static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble
|
||||
static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble
|
||||
static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color
|
||||
static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color
|
||||
static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check
|
||||
static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG
|
||||
static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text
|
||||
static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG
|
||||
static const iconMuted = Color(0xFF54656F);
|
||||
}
|
||||
|
||||
/// Chat channel overview screen with WhatsApp look and feel.
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
Future<List<ChatChannel>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.channels(widget.kcId);
|
||||
}
|
||||
|
||||
static const _typeLabels = {
|
||||
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
|
||||
'DIREKT': 'Direktnachricht',
|
||||
'LT_UEBERGREIFEND': 'Leitungsteam',
|
||||
'BROADCAST': 'Ankündigungen',
|
||||
'GRUPPE': 'Gruppenchat',
|
||||
};
|
||||
|
||||
static const _typeIcons = {
|
||||
'GEMEINDE_GRUPPE': Icons.people_alt_rounded,
|
||||
'DIREKT': Icons.person_rounded,
|
||||
'LT_UEBERGREIFEND': Icons.shield_rounded,
|
||||
'BROADCAST': Icons.campaign_rounded,
|
||||
'GRUPPE': Icons.groups_rounded,
|
||||
};
|
||||
|
||||
static const _typeColors = {
|
||||
'GEMEINDE_GRUPPE': Color(0xFF008069),
|
||||
'DIREKT': Color(0xFF2F7CFF),
|
||||
'LT_UEBERGREIFEND': Color(0xFF6C5CE7),
|
||||
'BROADCAST': Color(0xFFF17C20),
|
||||
'GRUPPE': Color(0xFF0984E3),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: WhatsAppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 1,
|
||||
iconTheme: IconThemeData(color: Colors.white),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('Chats'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Optionen',
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<ChatChannel>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: WhatsAppColors.primary),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
||||
const SizedBox(height: 12),
|
||||
Text('${snap.error}', textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final channels = snap.data!;
|
||||
if (channels.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Keine Kanäle sichtbar',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: channels.length,
|
||||
separatorBuilder: (_, __) => const Divider(
|
||||
indent: 80,
|
||||
endIndent: 16,
|
||||
height: 1,
|
||||
thickness: 0.8,
|
||||
color: Color(0xFFF0F2F5),
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final c = channels[i];
|
||||
final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type);
|
||||
final icon = _typeIcons[c.type] ?? Icons.chat_rounded;
|
||||
final color = _typeColors[c.type] ?? WhatsAppColors.primary;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: CircleAvatar(
|
||||
radius: 25,
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
child: Icon(icon, color: color, size: 28),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WhatsAppColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
_typeLabels[c.type] ?? c.type,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: WhatsAppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _ChannelMessages(
|
||||
channelId: c.id,
|
||||
title: label,
|
||||
subtitle: _typeLabels[c.type] ?? c.type,
|
||||
icon: icon,
|
||||
iconColor: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// WhatsApp-style chat message screen.
|
||||
class _ChannelMessages extends StatefulWidget {
|
||||
const _ChannelMessages({
|
||||
required this.channelId,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
|
||||
@override
|
||||
State<_ChannelMessages> createState() => _ChannelMessagesState();
|
||||
}
|
||||
|
||||
class _ChannelMessagesState extends State<_ChannelMessages> {
|
||||
final List<ChatMessage> _messages = [];
|
||||
final _composer = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
ChatSocket? _socket;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
String _wsStatus = 'verbinde…';
|
||||
bool _hasText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_composer.addListener(() {
|
||||
final hasText = _composer.text.trim().isNotEmpty;
|
||||
if (hasText != _hasText) {
|
||||
setState(() => _hasText = hasText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_socket != null) return;
|
||||
final api = AppScope.of(context).api;
|
||||
_load(api);
|
||||
_socket = ChatSocket(api.chatWsUri())
|
||||
..connect(widget.channelId)
|
||||
..messages.listen(_onIncoming)
|
||||
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
|
||||
}
|
||||
|
||||
Future<void> _load(Api api) async {
|
||||
try {
|
||||
final history = await api.messages(widget.channelId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages
|
||||
..clear()
|
||||
..addAll(history);
|
||||
_loading = false;
|
||||
});
|
||||
_jump();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void _onIncoming(ChatMessage m) {
|
||||
if (!mounted) return;
|
||||
setState(() => _messages.add(m));
|
||||
_jump();
|
||||
}
|
||||
|
||||
void _jump() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
_scroll.animateTo(
|
||||
_scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _composer.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_socket?.sendMessage(widget.channelId, text);
|
||||
_composer.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_socket?.dispose();
|
||||
_composer.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatTime(String rawDate) {
|
||||
final dt = DateTime.tryParse(rawDate)?.toLocal();
|
||||
if (dt == null) return rawDate;
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
String _formatDateHeader(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(date.year, date.month, date.day);
|
||||
|
||||
if (messageDate == today) {
|
||||
return 'HEUTE';
|
||||
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
||||
return 'GESTERN';
|
||||
} else {
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
return '$d.$m.${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
Color _getSenderColor(String id) {
|
||||
final colors = [
|
||||
const Color(0xFF1E88E5),
|
||||
const Color(0xFFE53935),
|
||||
const Color(0xFF8E24AA),
|
||||
const Color(0xFF3949AB),
|
||||
const Color(0xFF00897B),
|
||||
const Color(0xFFD81B60),
|
||||
const Color(0xFFFB8C00),
|
||||
const Color(0xFF43A047),
|
||||
];
|
||||
var hash = 0;
|
||||
for (var i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF;
|
||||
}
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final identity = AppScope.of(context).identity;
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: WhatsAppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 1,
|
||||
iconTheme: IconThemeData(color: Colors.white),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: WhatsAppColors.chatBackground,
|
||||
appBar: AppBar(
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 19,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.2),
|
||||
child: Icon(widget.icon, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
_wsStatus == 'verbunden' || _wsStatus == 'connected'
|
||||
? 'online'
|
||||
: _wsStatus,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Videoanruf',
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.call_rounded),
|
||||
tooltip: 'Anruf',
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Optionen',
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// WhatsApp Doodle Pattern Background
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _WhatsAppDoodlePainter(),
|
||||
),
|
||||
),
|
||||
// Chat Content
|
||||
Column(
|
||||
children: [
|
||||
Expanded(child: _buildMessagesList(context, identity)),
|
||||
_buildComposer(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesList(BuildContext context, Identity? identity) {
|
||||
if (_loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: WhatsAppColors.primary),
|
||||
);
|
||||
}
|
||||
if (_loadError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
||||
const SizedBox(height: 12),
|
||||
Text(_loadError!, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_messages.isEmpty) {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: WhatsAppColors.dateBadgeBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WhatsAppColors.dateBadgeText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final items = <Widget>[];
|
||||
DateTime? lastDate;
|
||||
|
||||
for (var i = 0; i < _messages.length; i++) {
|
||||
final m = _messages[i];
|
||||
final msgDate = DateTime.tryParse(m.createdAt)?.toLocal();
|
||||
|
||||
// Insert date divider if day changed
|
||||
if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) {
|
||||
items.add(_buildDateHeader(_formatDateHeader(msgDate)));
|
||||
lastDate = msgDate;
|
||||
}
|
||||
|
||||
final isMe = (identity?.kind == SessionKind.user &&
|
||||
m.senderUserId != null &&
|
||||
m.senderUserId == identity?.userId) ||
|
||||
(identity?.kind == SessionKind.guest &&
|
||||
m.senderGuestId != null &&
|
||||
m.senderGuestId == identity?.guestId);
|
||||
|
||||
items.add(_buildMessageBubble(m, isMe));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateHeader(String text) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: WhatsAppColors.dateBadgeBg,
|
||||
borderRadius: BorderRadius.circular(7.5),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x10000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
color: WhatsAppColors.dateBadgeText,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChatMessage m, bool isMe) {
|
||||
final timeStr = _formatTime(m.createdAt);
|
||||
final senderId = m.senderUserId ?? m.senderGuestId;
|
||||
final showSender = !isMe && senderId != null;
|
||||
final senderColor = showSender ? _getSenderColor(senderId) : Colors.black;
|
||||
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||||
minWidth: 80,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 4, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(12),
|
||||
topRight: const Radius.circular(12),
|
||||
bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2),
|
||||
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12),
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x18000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showSender) ...[
|
||||
Text(
|
||||
m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: senderColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.bottom,
|
||||
spacing: 8,
|
||||
runSpacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
m.body,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: WhatsAppColors.textPrimary,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
timeStr,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: WhatsAppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 3),
|
||||
const Icon(
|
||||
Icons.done_all_rounded,
|
||||
size: 15,
|
||||
color: WhatsAppColors.checkmarkBlue,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComposer(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
color: WhatsAppColors.iconMuted,
|
||||
splashRadius: 20,
|
||||
onPressed: () {},
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _composer,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Nachricht',
|
||||
hintStyle: TextStyle(
|
||||
color: WhatsAppColors.textSecondary,
|
||||
fontSize: 15.5,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4),
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.attach_file_rounded),
|
||||
color: WhatsAppColors.iconMuted,
|
||||
splashRadius: 20,
|
||||
onPressed: () {},
|
||||
),
|
||||
if (!_hasText)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.camera_alt_rounded),
|
||||
color: WhatsAppColors.iconMuted,
|
||||
splashRadius: 20,
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: WhatsAppColors.accent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x28000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded),
|
||||
color: Colors.white,
|
||||
splashRadius: 24,
|
||||
onPressed: _send,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for the iconic subtle WhatsApp background doodle canvas.
|
||||
class _WhatsAppDoodlePainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFF4A6B82).withValues(alpha: 0.05)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.3;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = const Color(0xFF4A6B82).withValues(alpha: 0.035)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
const spacing = 75.0;
|
||||
final rows = (size.height / spacing).ceil() + 1;
|
||||
final cols = (size.width / spacing).ceil() + 1;
|
||||
|
||||
for (var r = 0; r < rows; r++) {
|
||||
for (var c = 0; c < cols; c++) {
|
||||
final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0);
|
||||
final y = r * spacing;
|
||||
final type = (r * 7 + c * 11) % 6;
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(x, y);
|
||||
|
||||
switch (type) {
|
||||
case 0: // Chat bubble doodle
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
const Rect.fromLTWH(-10, -8, 20, 16),
|
||||
const Radius.circular(5),
|
||||
);
|
||||
canvas.drawRRect(rrect, fillPaint);
|
||||
canvas.drawRRect(rrect, paint);
|
||||
break;
|
||||
case 1: // Small Star
|
||||
final path = Path();
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final angle = i * 4 * math.pi / 5 - math.pi / 2;
|
||||
final px = 8 * math.cos(angle);
|
||||
final py = 8 * math.sin(angle);
|
||||
if (i == 0) {
|
||||
path.moveTo(px, py);
|
||||
} else {
|
||||
path.lineTo(px, py);
|
||||
}
|
||||
}
|
||||
path.close();
|
||||
canvas.drawPath(path, fillPaint);
|
||||
canvas.drawPath(path, paint);
|
||||
break;
|
||||
case 2: // Heart doodle
|
||||
final path = Path();
|
||||
path.moveTo(0, 4);
|
||||
path.cubicTo(-6, -2, -10, -8, 0, -10);
|
||||
path.cubicTo(10, -8, 6, -2, 0, 4);
|
||||
canvas.drawPath(path, fillPaint);
|
||||
canvas.drawPath(path, paint);
|
||||
break;
|
||||
case 3: // Musical note
|
||||
canvas.drawCircle(const Offset(-4, 4), 3, fillPaint);
|
||||
canvas.drawCircle(const Offset(-4, 4), 3, paint);
|
||||
canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint);
|
||||
canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint);
|
||||
break;
|
||||
case 4: // Coffee / cup
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
const Rect.fromLTWH(-7, -5, 14, 12),
|
||||
const Radius.circular(3),
|
||||
);
|
||||
canvas.drawRRect(rrect, fillPaint);
|
||||
canvas.drawRRect(rrect, paint);
|
||||
canvas.drawArc(
|
||||
const Rect.fromLTWH(4, -3, 6, 6),
|
||||
-math.pi / 2,
|
||||
math.pi,
|
||||
false,
|
||||
paint,
|
||||
);
|
||||
break;
|
||||
case 5: // Clock / circle
|
||||
canvas.drawCircle(Offset.zero, 7, fillPaint);
|
||||
canvas.drawCircle(Offset.zero, 7, paint);
|
||||
canvas.drawLine(Offset.zero, const Offset(0, -4), paint);
|
||||
canvas.drawLine(Offset.zero, const Offset(3, 0), paint);
|
||||
break;
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../browser.dart' as browser;
|
||||
import '../main.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// LT file management for one KC: upload with a visibility tier + list.
|
||||
class FilesAdminScreen extends StatefulWidget {
|
||||
const FilesAdminScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<FilesAdminScreen> createState() => _FilesAdminScreenState();
|
||||
}
|
||||
|
||||
class _FilesAdminScreenState extends State<FilesAdminScreen> {
|
||||
Future<List<FileEntry>>? _files;
|
||||
bool _uploading = false;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
static const _visibilities = {
|
||||
'ALLE': 'Alle (inkl. Konfis)',
|
||||
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
|
||||
'NUR_LT': 'Nur Leitungsteam',
|
||||
};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_files ??= _api.files(widget.kcId);
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _files = _api.files(widget.kcId));
|
||||
|
||||
Future<void> _upload() async {
|
||||
final api = _api;
|
||||
final picked = await browser.pickFile();
|
||||
if (picked == null || !mounted) return;
|
||||
final visibility = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => SimpleDialog(
|
||||
title: Text('Sichtbarkeit für „${picked.name}“'),
|
||||
children: [
|
||||
for (final e in _visibilities.entries)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(e.key),
|
||||
child: Text(e.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (visibility == null || !mounted) return;
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
await api.uploadFile(widget.kcId, picked.name, picked.bytes, visibility);
|
||||
if (mounted) _reload();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dateien (LT)')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _uploading ? null : _upload,
|
||||
icon: _uploading
|
||||
? const SizedBox(
|
||||
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Hochladen'),
|
||||
),
|
||||
body: FutureBuilder<List<FileEntry>>(
|
||||
future: _files,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
|
||||
final files = snap.data!;
|
||||
if (files.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Dateien.'));
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final f in files)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(f.filename),
|
||||
subtitle: Text(_visibilities[f.visibility] ?? f.visibility),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class FilesScreen extends StatefulWidget {
|
||||
const FilesScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<FilesScreen> createState() => _FilesScreenState();
|
||||
}
|
||||
|
||||
class _FilesScreenState extends State<FilesScreen> {
|
||||
Future<List<FileEntry>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.files(widget.kcId);
|
||||
}
|
||||
|
||||
static const _visibilityLabels = {
|
||||
'ALLE': 'Alle',
|
||||
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
|
||||
'NUR_LT': 'Nur Leitungsteam',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dateien')),
|
||||
body: FutureBuilder<List<FileEntry>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snap.error}', textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
final files = snap.data!;
|
||||
if (files.isEmpty) {
|
||||
return const Center(child: Text('Keine Dateien freigegeben.'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: files.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final f = files[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(f.filename),
|
||||
subtitle: Text(_visibilityLabels[f.visibility] ?? f.visibility),
|
||||
trailing: const Icon(Icons.download),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Download-URL: ${AppScope.of(context).api.fileDownloadUrl(f.id)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import '../theme.dart';
|
||||
import 'admin_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'files_screen.dart';
|
||||
import 'verantwortliche_register_screen.dart';
|
||||
import 'wahl_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
final id = state.identity!;
|
||||
final kcId = id.kcId;
|
||||
|
||||
final needsVerantwRegistration = id.kind == SessionKind.user &&
|
||||
!id.isLeitungsteam &&
|
||||
id.memberships.isEmpty;
|
||||
|
||||
final tiles = <Widget>[
|
||||
if (id.isLeitungsteam)
|
||||
_NavTile(
|
||||
icon: Icons.admin_panel_settings,
|
||||
title: 'Verwaltung',
|
||||
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
|
||||
onTap: () => _open(context, const AdminScreen()),
|
||||
),
|
||||
if (needsVerantwRegistration)
|
||||
_NavTile(
|
||||
icon: Icons.how_to_reg,
|
||||
title: 'Als Verantwortliche/r registrieren',
|
||||
subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten',
|
||||
onTap: () => _open(context, const VerantwortlicheRegisterScreen()),
|
||||
),
|
||||
if (id.kind == SessionKind.guest)
|
||||
_NavTile(
|
||||
icon: Icons.how_to_vote,
|
||||
title: 'Workshop-Wahl',
|
||||
subtitle: 'Deine Wünsche abgeben',
|
||||
onTap: () => _open(context, const WahlScreen()),
|
||||
),
|
||||
if (kcId != null)
|
||||
_NavTile(
|
||||
icon: Icons.folder_shared,
|
||||
title: 'Dateien',
|
||||
subtitle: 'Freigegebene Dateien ansehen',
|
||||
onTap: () => _open(context, FilesScreen(kcId: kcId)),
|
||||
),
|
||||
if (kcId != null)
|
||||
_NavTile(
|
||||
icon: Icons.forum,
|
||||
title: 'Chat',
|
||||
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
|
||||
onTap: () => _open(context, ChatScreen(kcId: kcId)),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('KC-App'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Abmelden',
|
||||
onPressed: state.logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_IdentityCard(id: id),
|
||||
const SizedBox(height: 16),
|
||||
...tiles,
|
||||
if (tiles.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 24),
|
||||
child: Text(
|
||||
'Für diesen Account gibt es hier noch keine Ansichten. '
|
||||
'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen '
|
||||
'Dateien und Chat.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _open(BuildContext context, Widget screen) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen));
|
||||
}
|
||||
}
|
||||
|
||||
class _IdentityCard extends StatelessWidget {
|
||||
const _IdentityCard({required this.id});
|
||||
final Identity id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = <String>[
|
||||
if (id.email != null) id.email!,
|
||||
if (id.isLeitungsteam)
|
||||
'Leitungsteam-Rechte gelten KC-übergreifend.'
|
||||
else if (id.memberships.length > 1)
|
||||
'${id.memberships.length} Zuordnungen',
|
||||
];
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [KcColors.blue, KcColors.teal],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.person, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
|
||||
for (final l in lines) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(l, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTile extends StatelessWidget {
|
||||
const _NavTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: KcColors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: KcColors.blue),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// A single login screen — one card, no tabs, no role switcher. The KC-Code
|
||||
/// field drives Konfi vs. Leitungsteam: a plain code reveals the Konfi name
|
||||
/// fields; appending "LT" to the code (e.g. "ABC123LT") reveals the
|
||||
/// Leitungsteam Authentik button instead. Gemeinde Teamer:in has its own
|
||||
/// section below, logging in with the Gemeinde name instead of an email.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _Brand(),
|
||||
const SizedBox(height: 28),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: const [
|
||||
_KonfiOrLeitungsteamSection(),
|
||||
Divider(height: 40),
|
||||
_TeamerSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Brand extends StatelessWidget {
|
||||
const _Brand();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [KcColors.blue, KcColors.teal],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.castle_outlined, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'KC-App',
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: KcColors.navy),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('Konfi-Castle Events', style: TextStyle(fontSize: 14, color: KcColors.slate)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared submit-button + error handling.
|
||||
class _FormShell extends StatefulWidget {
|
||||
const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
|
||||
final List<Widget> fields;
|
||||
final Future<void> Function() onSubmit;
|
||||
final String submitLabel;
|
||||
|
||||
@override
|
||||
State<_FormShell> createState() => _FormShellState();
|
||||
}
|
||||
|
||||
class _FormShellState extends State<_FormShell> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _run() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.onSubmit();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...widget.fields,
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _run,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 18, width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: Text(widget.submitLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextField _field(TextEditingController c, String label,
|
||||
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
|
||||
TextField(
|
||||
controller: c,
|
||||
obscureText: obscure,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
|
||||
),
|
||||
);
|
||||
|
||||
const _fieldGap = SizedBox(height: 12);
|
||||
|
||||
/// One code field drives two different logins: a plain KC-Code reveals the
|
||||
/// Konfi name fields; a code ending in "LT" (e.g. "ABC123LT") reveals the
|
||||
/// Leitungsteam Authentik button instead — no separate role picker needed.
|
||||
class _KonfiOrLeitungsteamSection extends StatefulWidget {
|
||||
const _KonfiOrLeitungsteamSection();
|
||||
@override
|
||||
State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
|
||||
}
|
||||
|
||||
class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
|
||||
final _code = TextEditingController();
|
||||
final _first = TextEditingController();
|
||||
final _last = TextEditingController();
|
||||
|
||||
bool get _isLeitungsteamCode {
|
||||
final c = _code.text.trim();
|
||||
final upper = c.toUpperCase();
|
||||
final lower = c.toLowerCase();
|
||||
return upper == 'LT' || (c.length > 2 && upper.endsWith('LT')) || lower == 'login' || lower == 'sso';
|
||||
}
|
||||
|
||||
/// The KC-Code with a trailing "LT" trigger stripped back off, so
|
||||
/// "ABC123LT" still resolves to the real invite code "ABC123".
|
||||
String get _plainCode {
|
||||
final c = _code.text.trim();
|
||||
if (c.toUpperCase() == 'LT' || c.toLowerCase() == 'login' || c.toLowerCase() == 'sso') return '';
|
||||
return (c.length > 2 && c.toUpperCase().endsWith('LT'))
|
||||
? c.substring(0, c.length - 2)
|
||||
: c;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_field(
|
||||
_code,
|
||||
'KC-Code',
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Konfi: gib deinen KC-Code ein. Leitungsteam: gib "LT" ein oder hänge "LT" an den '
|
||||
'Code an (z. B. "LT" oder "ABC123LT").',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _code.text.trim().isEmpty
|
||||
? const SizedBox.shrink(key: ValueKey('empty'))
|
||||
: _isLeitungsteamCode
|
||||
? _LeitungsteamLogin(key: const ValueKey('lt'), state: state)
|
||||
: Column(
|
||||
key: const ValueKey('konfi'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_field(_first, 'Vorname', icon: Icons.badge_outlined),
|
||||
_fieldGap,
|
||||
_field(_last, 'Nachname'),
|
||||
const SizedBox(height: 4),
|
||||
_FormShell(
|
||||
submitLabel: 'Los geht\'s',
|
||||
fields: const [],
|
||||
onSubmit: () => state.guestLogin(
|
||||
_plainCode,
|
||||
_first.text.trim(),
|
||||
_last.text.trim(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Meldest du dich erneut mit demselben Code und Namen '
|
||||
'an, kommst du in deinen bestehenden Account zurück.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeitungsteamLogin extends StatelessWidget {
|
||||
const _LeitungsteamLogin({super.key, required this.state});
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (state.authError != null) ...[
|
||||
Text(state.authError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: () => state.beginOidcLogin(),
|
||||
icon: const Icon(Icons.login, size: 20),
|
||||
label: const Text('Mit Konfi-Castle-ID anmelden'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und '
|
||||
'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's
|
||||
/// what a Teamer actually thinks of as "their" login. Invite redemption for
|
||||
/// a first-time account is folded in underneath.
|
||||
class _TeamerSection extends StatefulWidget {
|
||||
const _TeamerSection();
|
||||
@override
|
||||
State<_TeamerSection> createState() => _TeamerSectionState();
|
||||
}
|
||||
|
||||
class _TeamerSectionState extends State<_TeamerSection> {
|
||||
final _gemeinde = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
bool _showInvite = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
_FormShell(
|
||||
fields: [
|
||||
_field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined),
|
||||
_fieldGap,
|
||||
_field(_password, 'Passwort', obscure: true, icon: Icons.lock_outline),
|
||||
],
|
||||
onSubmit: () => state.teamLogin(
|
||||
gemeindeName: _gemeinde.text.trim(),
|
||||
password: _password.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() => _showInvite = !_showInvite),
|
||||
child: Text(_showInvite
|
||||
? 'Einladung ausblenden'
|
||||
: 'Noch kein Konto? Einladung einlösen'),
|
||||
),
|
||||
),
|
||||
if (_showInvite) ...[
|
||||
const Divider(height: 28),
|
||||
const _InviteForm(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InviteForm extends StatefulWidget {
|
||||
const _InviteForm();
|
||||
@override
|
||||
State<_InviteForm> createState() => _InviteFormState();
|
||||
}
|
||||
|
||||
class _InviteFormState extends State<_InviteForm> {
|
||||
final _token = TextEditingController();
|
||||
final _first = TextEditingController();
|
||||
final _last = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Teamer:in-Einladung einlösen',
|
||||
style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
_FormShell(
|
||||
submitLabel: 'Konto anlegen',
|
||||
fields: [
|
||||
_field(_token, 'Einladungscode / Token'),
|
||||
_fieldGap,
|
||||
_field(_first, 'Vorname'),
|
||||
_fieldGap,
|
||||
_field(_last, 'Nachname'),
|
||||
_fieldGap,
|
||||
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
|
||||
_fieldGap,
|
||||
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
|
||||
],
|
||||
onSubmit: () => state.redeemInvite(
|
||||
token: _token.text.trim(),
|
||||
first: _first.text.trim(),
|
||||
last: _last.text.trim(),
|
||||
password: _password.text,
|
||||
email: _email.text.trim(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Teamer administration for one Gemeinde — usable by the Leitungsteam or the
|
||||
/// responsible Gemeinde Verantwortliche/r.
|
||||
class TeamerAdminScreen extends StatefulWidget {
|
||||
const TeamerAdminScreen({
|
||||
super.key,
|
||||
required this.gemeindeId,
|
||||
required this.gemeindeName,
|
||||
});
|
||||
final String gemeindeId;
|
||||
final String gemeindeName;
|
||||
|
||||
@override
|
||||
State<TeamerAdminScreen> createState() => _TeamerAdminScreenState();
|
||||
}
|
||||
|
||||
class _TeamerAdminScreenState extends State<TeamerAdminScreen> {
|
||||
Future<List<TeamerAccount>>? _teamer;
|
||||
Future<List<TeamerInvite>>? _invites;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_teamer ??= _api.teamerFor(widget.gemeindeId);
|
||||
_invites ??= _api.teamerInvitesFor(widget.gemeindeId);
|
||||
}
|
||||
|
||||
void _reloadTeamer() =>
|
||||
setState(() => _teamer = _api.teamerFor(widget.gemeindeId));
|
||||
void _reloadInvites() =>
|
||||
setState(() => _invites = _api.teamerInvitesFor(widget.gemeindeId));
|
||||
|
||||
Future<void> _addTeamer() async {
|
||||
final api = _api;
|
||||
final v = await showDialog<(String, String, String, String)>(
|
||||
context: context,
|
||||
builder: (_) => const _NewTeamerDialog(),
|
||||
);
|
||||
if (v == null || !mounted) return;
|
||||
try {
|
||||
await api.createTeamer(
|
||||
widget.gemeindeId,
|
||||
firstName: v.$1,
|
||||
lastName: v.$2,
|
||||
email: v.$3,
|
||||
password: v.$4,
|
||||
);
|
||||
if (mounted) _reloadTeamer();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addInvite({required bool personal}) async {
|
||||
final api = _api;
|
||||
String? email;
|
||||
if (personal) {
|
||||
email = await promptText(context, 'E-Mail-Invite', 'E-Mail-Adresse');
|
||||
if (email == null || email.isEmpty || !mounted) return;
|
||||
}
|
||||
try {
|
||||
final inv = await api.createTeamerInvite(widget.gemeindeId, email: email);
|
||||
if (!mounted) return;
|
||||
_reloadInvites();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Invite erstellt'),
|
||||
content: SelectableText(
|
||||
personal
|
||||
? 'E-Mail an ${inv.email} ausgelöst.\n\nToken: ${inv.token}'
|
||||
: 'Gruppen-Link-Token (mehrfach nutzbar):\n\n${inv.token}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Teamer:innen · ${widget.gemeindeName}')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SectionHeader('Konten', action: TextButton.icon(
|
||||
onPressed: _addTeamer,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Anlegen'),
|
||||
)),
|
||||
FutureBuilder<List<TeamerAccount>>(
|
||||
future: _teamer,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final list = snap.data!;
|
||||
if (list.isEmpty) return const Text('Noch keine Teamer:innen.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final t in list)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(t.name.isEmpty ? t.email : t.name),
|
||||
subtitle: Text(t.email),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 40),
|
||||
SectionHeader('Einladungen', action: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _addInvite(personal: false),
|
||||
child: const Text('Gruppen-Link'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _addInvite(personal: true),
|
||||
child: const Text('per E-Mail'),
|
||||
),
|
||||
],
|
||||
)),
|
||||
FutureBuilder<List<TeamerInvite>>(
|
||||
future: _invites,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final list = snap.data!;
|
||||
if (list.isEmpty) return const Text('Keine Einladungen.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final i in list)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(i.revoked
|
||||
? Icons.block
|
||||
: i.email != null
|
||||
? Icons.mail
|
||||
: Icons.link),
|
||||
title: Text(i.email ?? 'Gruppen-Link'),
|
||||
subtitle: Text(
|
||||
'${i.usedCount}${i.maxUses != null ? '/${i.maxUses}' : ''} genutzt'
|
||||
'${i.revoked ? ' · widerrufen' : ''}',
|
||||
),
|
||||
trailing: SelectableText(
|
||||
i.token.substring(0, 8),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewTeamerDialog extends StatefulWidget {
|
||||
const _NewTeamerDialog();
|
||||
@override
|
||||
State<_NewTeamerDialog> createState() => _NewTeamerDialogState();
|
||||
}
|
||||
|
||||
class _NewTeamerDialogState extends State<_NewTeamerDialog> {
|
||||
final _first = TextEditingController();
|
||||
final _last = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Teamer:in anlegen'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _first, decoration: const InputDecoration(labelText: 'Vorname')),
|
||||
TextField(controller: _last, decoration: const InputDecoration(labelText: 'Nachname')),
|
||||
TextField(controller: _email, decoration: const InputDecoration(labelText: 'E-Mail')),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Passwort (min. 8)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
_first.text.trim(),
|
||||
_last.text.trim(),
|
||||
_email.text.trim(),
|
||||
_password.text,
|
||||
)),
|
||||
child: const Text('Anlegen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Small shared widgets/helpers used across the admin screens.
|
||||
|
||||
void toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
class ErrorText extends StatelessWidget {
|
||||
const ErrorText(this.message, {super.key, this.onRetry});
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
const SectionHeader(this.title, {super.key, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-line text prompt dialog. Returns the trimmed value or null.
|
||||
Future<String?> promptText(BuildContext context, String title, String label) {
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: label),
|
||||
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// Self-registration as a Gemeinde Verantwortliche/r: enter the KC invite
|
||||
/// code, pick your Gemeinde, send the request. The result is a PENDING
|
||||
/// membership a Leitungsteam member has to approve.
|
||||
class VerantwortlicheRegisterScreen extends StatefulWidget {
|
||||
const VerantwortlicheRegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<VerantwortlicheRegisterScreen> createState() =>
|
||||
_VerantwortlicheRegisterScreenState();
|
||||
}
|
||||
|
||||
class _VerantwortlicheRegisterScreenState
|
||||
extends State<VerantwortlicheRegisterScreen> {
|
||||
final _code = TextEditingController();
|
||||
String? _kcName;
|
||||
List<(String id, String name)> _gemeinden = [];
|
||||
String? _selectedGemeinde;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
String? _done;
|
||||
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
Future<void> _resolve() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_kcName = null;
|
||||
_gemeinden = [];
|
||||
});
|
||||
try {
|
||||
final res = await _api.resolveInvite(_code.text.trim());
|
||||
setState(() {
|
||||
_kcName = res['kcName'] as String?;
|
||||
_gemeinden = ((res['gemeinden'] as List<dynamic>?) ?? [])
|
||||
.map((g) => (g['id'] as String, g['name'] as String))
|
||||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_selectedGemeinde == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final res =
|
||||
await _api.registerVerantwortliche(_code.text.trim(), _selectedGemeinde!);
|
||||
setState(() => _done =
|
||||
'Anfrage gesendet (Status: ${res['status']}). Ein Leitungsteam-Mitglied '
|
||||
'muss dich noch freischalten.');
|
||||
} catch (e) {
|
||||
setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Als Verantwortliche/r registrieren')),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
if (_done != null) ...[
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(_done!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => AppScope.of(context).logout(),
|
||||
child: const Text('Abmelden'),
|
||||
),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _code,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'KC-Einladungscode',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : _resolve,
|
||||
child: const Text('KC suchen'),
|
||||
),
|
||||
if (_kcName != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('KC: $_kcName',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedGemeinde,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Deine Gemeinde',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final g in _gemeinden)
|
||||
DropdownMenuItem(value: g.$1, child: Text(g.$2)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _selectedGemeinde = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: (_busy || _selectedGemeinde == null) ? null : _submit,
|
||||
child: const Text('Anfrage senden'),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../browser.dart' as browser;
|
||||
import '../main.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// LT: manage the Wahlen of one KC — create, add workshops, run the
|
||||
/// assignment algorithm, view the result.
|
||||
class WahlAdminScreen extends StatefulWidget {
|
||||
const WahlAdminScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<WahlAdminScreen> createState() => _WahlAdminScreenState();
|
||||
}
|
||||
|
||||
class _WahlAdminScreenState extends State<WahlAdminScreen> {
|
||||
Future<List<WahlAdmin>>? _future;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= _api.wahlenForKc(widget.kcId);
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _future = _api.wahlenForKc(widget.kcId));
|
||||
|
||||
Future<void> _create() async {
|
||||
final api = _api;
|
||||
final v = await showDialog<(String, String, String)>(
|
||||
context: context,
|
||||
builder: (_) => const _NewWahlDialog(),
|
||||
);
|
||||
if (v == null || !mounted) return;
|
||||
try {
|
||||
await api.createWahl(widget.kcId, v.$1, v.$2, v.$3);
|
||||
if (mounted) _reload();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Wahlen')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Wahl'),
|
||||
),
|
||||
body: FutureBuilder<List<WahlAdmin>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
|
||||
final wahlen = snap.data!;
|
||||
if (wahlen.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Wahlen. Unten anlegen.'));
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final w in wahlen)
|
||||
ListTile(
|
||||
leading: Icon(w.isOpen ? Icons.lock_open : Icons.lock),
|
||||
title: Text(w.name),
|
||||
subtitle: Text('${w.datumsSchluessel} · Teil ${w.teil}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => WahlDetailScreen(wahl: w)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WahlDetailScreen extends StatefulWidget {
|
||||
const WahlDetailScreen({super.key, required this.wahl});
|
||||
final WahlAdmin wahl;
|
||||
|
||||
@override
|
||||
State<WahlDetailScreen> createState() => _WahlDetailScreenState();
|
||||
}
|
||||
|
||||
class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
Future<List<WorkshopAdmin>>? _workshops;
|
||||
Future<List<ZuteilungRow>>? _results;
|
||||
Future<List<TeilnehmerRow>>? _teilnehmer;
|
||||
late bool _isOpen = widget.wahl.isOpen;
|
||||
List<WorkshopAdmin> _workshopCache = const [];
|
||||
bool _running = false;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_workshops ??= _api.workshopsForWahl(widget.wahl.id).then((w) {
|
||||
_workshopCache = w;
|
||||
return w;
|
||||
});
|
||||
_results ??= _api.zuteilungResults(widget.wahl.id);
|
||||
_teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id);
|
||||
}
|
||||
|
||||
void _reloadWorkshops() => setState(() {
|
||||
_workshops = _api.workshopsForWahl(widget.wahl.id).then((w) {
|
||||
_workshopCache = w;
|
||||
return w;
|
||||
});
|
||||
});
|
||||
void _reloadResults() =>
|
||||
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
|
||||
void _reloadTeilnehmer() =>
|
||||
setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id));
|
||||
|
||||
Future<void> _toggleOpen(bool value) async {
|
||||
final api = _api;
|
||||
setState(() => _isOpen = value);
|
||||
try {
|
||||
await api.setWahlOpen(widget.wahl.id, value);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isOpen = !value);
|
||||
toast(context, '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportCsv() async {
|
||||
final api = _api;
|
||||
try {
|
||||
final csv = await api.zuteilungCsv(widget.wahl.id);
|
||||
browser.downloadText('zuteilung-${widget.wahl.name}.csv', csv);
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forceFor(TeilnehmerRow t) async {
|
||||
final api = _api;
|
||||
final workshopId = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => SimpleDialog(
|
||||
title: Text('Zuteilung für ${t.name}'),
|
||||
children: [
|
||||
for (final w in _workshopCache)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(w.id),
|
||||
child: Text(w.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (workshopId == null || !mounted) return;
|
||||
try {
|
||||
await api.forceZuteilung(widget.wahl.id, t.id, workshopId);
|
||||
if (mounted) _reloadTeilnehmer();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addWorkshop() async {
|
||||
final api = _api;
|
||||
final v = await showDialog<(String, int, int)>(
|
||||
context: context,
|
||||
builder: (_) => const _NewWorkshopDialog(),
|
||||
);
|
||||
if (v == null || !mounted) return;
|
||||
try {
|
||||
await api.createWorkshop(widget.wahl.id, v.$1, v.$2, v.$3);
|
||||
if (mounted) _reloadWorkshops();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
final api = _api;
|
||||
setState(() => _running = true);
|
||||
try {
|
||||
await api.runZuteilung(widget.wahl.id);
|
||||
if (mounted) _reloadResults();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.wahl.name)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Wahl geöffnet'),
|
||||
subtitle: Text(_isOpen
|
||||
? 'Konfis können Wünsche abgeben'
|
||||
: 'Geschlossen — keine neuen Einreichungen'),
|
||||
value: _isOpen,
|
||||
onChanged: _toggleOpen,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SectionHeader('Workshops', action: TextButton.icon(
|
||||
onPressed: _addWorkshop,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Hinzufügen'),
|
||||
)),
|
||||
FutureBuilder<List<WorkshopAdmin>>(
|
||||
future: _workshops,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final ws = snap.data!;
|
||||
if (ws.isEmpty) return const Text('Noch keine Workshops.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final w in ws)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.groups),
|
||||
title: Text(w.name),
|
||||
subtitle: Text('Kapazität ${w.kapazitaet} · min. ${w.minTeilnehmer}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 40),
|
||||
SectionHeader('Teilnehmer:innen'),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<TeilnehmerRow>>(
|
||||
future: _teilnehmer,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final rows = snap.data!;
|
||||
if (rows.isEmpty) return const Text('Noch keine Einreichungen.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final t in rows)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(t.name),
|
||||
subtitle: Text('Wünsche: ${t.prioritaeten.length}'
|
||||
'${t.forcedWorkshopId != null ? ' · fest zugeteilt' : ''}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _forceFor(t),
|
||||
child: const Text('Zuteilen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 40),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Zuteilung',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'CSV exportieren',
|
||||
onPressed: _exportCsv,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _running ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: const Text('Ausführen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<ZuteilungRow>>(
|
||||
future: _results,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final rows = snap.data!;
|
||||
if (rows.isEmpty) {
|
||||
return const Text('Noch keine Zuteilung berechnet.');
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final r in rows)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(r.name),
|
||||
subtitle: Text(r.workshopName ?? 'UNZUGETEILT'),
|
||||
trailing: Text(
|
||||
r.isForced
|
||||
? 'fest'
|
||||
: r.wunschRang > 0
|
||||
? 'Wunsch ${r.wunschRang}'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewWahlDialog extends StatefulWidget {
|
||||
const _NewWahlDialog();
|
||||
@override
|
||||
State<_NewWahlDialog> createState() => _NewWahlDialogState();
|
||||
}
|
||||
|
||||
class _NewWahlDialogState extends State<_NewWahlDialog> {
|
||||
final _name = TextEditingController();
|
||||
final _datum = TextEditingController();
|
||||
final _teil = TextEditingController(text: '1');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neue Wahl'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
|
||||
TextField(
|
||||
controller: _datum,
|
||||
decoration: const InputDecoration(labelText: 'Datumsschlüssel (z. B. 2026-06-13)')),
|
||||
TextField(controller: _teil, decoration: const InputDecoration(labelText: 'Teil')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
(_name.text.trim(), _datum.text.trim(), _teil.text.trim()),
|
||||
),
|
||||
child: const Text('Anlegen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewWorkshopDialog extends StatefulWidget {
|
||||
const _NewWorkshopDialog();
|
||||
@override
|
||||
State<_NewWorkshopDialog> createState() => _NewWorkshopDialogState();
|
||||
}
|
||||
|
||||
class _NewWorkshopDialogState extends State<_NewWorkshopDialog> {
|
||||
final _name = TextEditingController();
|
||||
final _kap = TextEditingController(text: '12');
|
||||
final _min = TextEditingController(text: '0');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neuer Workshop'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
|
||||
TextField(
|
||||
controller: _kap,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Kapazität')),
|
||||
TextField(
|
||||
controller: _min,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Mindestteilnehmer')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
_name.text.trim(),
|
||||
int.tryParse(_kap.text) ?? 0,
|
||||
int.tryParse(_min.text) ?? 0,
|
||||
)),
|
||||
child: const Text('Anlegen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class WahlScreen extends StatefulWidget {
|
||||
const WahlScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WahlScreen> createState() => _WahlScreenState();
|
||||
}
|
||||
|
||||
class _WahlScreenState extends State<WahlScreen> {
|
||||
Future<GuestOverview>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.guestWahlOverview();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() {
|
||||
_future = AppScope.of(context).api.guestWahlOverview();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Workshop-Wahl'),
|
||||
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
FutureBuilder<GuestOverview>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorView(message: '${snap.error}', onRetry: _reload);
|
||||
}
|
||||
final data = snap.data!;
|
||||
if (data.wahlen.isEmpty) {
|
||||
return const Center(child: Text('Aktuell ist keine Wahl geöffnet.'));
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(data.kcName, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
for (final w in data.wahlen)
|
||||
_WahlCard(wahl: w, onSubmitted: _reload),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const _ErgebnisTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErgebnisTab extends StatefulWidget {
|
||||
const _ErgebnisTab();
|
||||
@override
|
||||
State<_ErgebnisTab> createState() => _ErgebnisTabState();
|
||||
}
|
||||
|
||||
class _ErgebnisTabState extends State<_ErgebnisTab> {
|
||||
Future<List<WahlResult>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.guestWahlResults();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = AppScope.of(context).api.guestWahlResults());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<WahlResult>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorView(message: '${snap.error}', onRetry: _reload);
|
||||
}
|
||||
final results = snap.data!;
|
||||
if (results.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Teilnahme an einer Wahl.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [for (final r in results) _ResultCard(result: r)],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultCard extends StatelessWidget {
|
||||
const _ResultCard({required this.result});
|
||||
final WahlResult result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color, detail) = switch (result.status) {
|
||||
WahlResultStatus.assigned => (
|
||||
result.workshopName ?? 'Zugeteilt',
|
||||
Colors.green,
|
||||
result.isForced
|
||||
? 'Fest zugeteilt (Leitungsteam)'
|
||||
: 'Wunsch ${result.wunschRang ?? '?'}',
|
||||
),
|
||||
WahlResultStatus.unassigned => (
|
||||
'Kein Platz frei',
|
||||
Theme.of(context).colorScheme.error,
|
||||
'Bitte beim Leitungsteam melden.',
|
||||
),
|
||||
WahlResultStatus.pending => (
|
||||
'Noch nicht zugeteilt',
|
||||
Theme.of(context).colorScheme.outline,
|
||||
'Die Zuteilung läuft noch.',
|
||||
),
|
||||
};
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.emoji_events, color: color),
|
||||
title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium),
|
||||
trailing: Text(detail, textAlign: TextAlign.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WahlCard extends StatefulWidget {
|
||||
const _WahlCard({required this.wahl, required this.onSubmitted});
|
||||
final Wahl wahl;
|
||||
final VoidCallback onSubmitted;
|
||||
|
||||
@override
|
||||
State<_WahlCard> createState() => _WahlCardState();
|
||||
}
|
||||
|
||||
class _WahlCardState extends State<_WahlCard> {
|
||||
late final List<String> _picked = [...?widget.wahl.meinePrioritaeten];
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
bool _done = false;
|
||||
|
||||
static const _maxPicks = 3;
|
||||
|
||||
void _toggle(String workshopId) {
|
||||
setState(() {
|
||||
if (_picked.contains(workshopId)) {
|
||||
_picked.remove(workshopId);
|
||||
} else if (_picked.length < _maxPicks) {
|
||||
_picked.add(workshopId);
|
||||
}
|
||||
_done = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked);
|
||||
setState(() => _done = true);
|
||||
widget.onSubmitted();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget.wahl;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(w.name, style: Theme.of(context).textTheme.titleLarge),
|
||||
Text('${w.datumsSchluessel} · Teil ${w.teil}',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). '
|
||||
'Die Zahl zeigt den Rang.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final ws in w.workshops)
|
||||
_WorkshopRow(
|
||||
workshop: ws,
|
||||
rank: _picked.indexOf(ws.id),
|
||||
enabled: !_busy,
|
||||
onTap: () => _toggle(ws.id),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: (_busy || _picked.isEmpty) ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Wünsche absenden'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (_done)
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 20),
|
||||
SizedBox(width: 4),
|
||||
Text('Gespeichert'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkshopRow extends StatelessWidget {
|
||||
const _WorkshopRow({
|
||||
required this.workshop,
|
||||
required this.rank,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
final Workshop workshop;
|
||||
final int rank;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = rank >= 0;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
selected ? '${rank + 1}' : '',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(workshop.name),
|
||||
subtitle: Text('Kapazität ${workshop.kapazitaet}'),
|
||||
trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Brand palette lifted from konfi-castle.com (Kubio theme CSS custom
|
||||
/// properties: --kubio-color-1..6) so the app's look leans on the same
|
||||
/// identity as the marketing site instead of Flutter's default Material
|
||||
/// purple.
|
||||
abstract final class KcColors {
|
||||
static const blue = Color(0xFF2F7CFF); // --kubio-color-1
|
||||
static const orange = Color(0xFFF17C20); // --kubio-color-2
|
||||
static const teal = Color(0xFF4EBA9A); // --kubio-color-3
|
||||
static const slate = Color(0xFF69768B); // --kubio-color-4
|
||||
static const navy = Color(0xFF2B2D42); // --kubio-color-6 (headings/text)
|
||||
static const surface = Color(0xFFF7F9FC);
|
||||
}
|
||||
|
||||
ThemeData buildKcTheme() {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: KcColors.blue,
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
primary: KcColors.blue,
|
||||
secondary: KcColors.orange,
|
||||
tertiary: KcColors.teal,
|
||||
surface: KcColors.surface,
|
||||
onSurface: KcColors.navy,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: KcColors.surface,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: KcColors.surface,
|
||||
foregroundColor: KcColors.navy,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: const TextStyle(
|
||||
color: KcColors.navy,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.06)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: KcColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: KcColors.blue, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: KcColors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: KcColors.blue,
|
||||
side: const BorderSide(color: KcColors.blue),
|
||||
minimumSize: const Size.fromHeight(46),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: SegmentedButton.styleFrom(
|
||||
selectedBackgroundColor: KcColors.blue,
|
||||
selectedForegroundColor: Colors.white,
|
||||
foregroundColor: KcColors.navy,
|
||||
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.15)),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
titleLarge: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w700),
|
||||
titleMedium: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w600),
|
||||
bodyMedium: TextStyle(color: KcColors.navy),
|
||||
bodySmall: TextStyle(color: KcColors.slate),
|
||||
labelMedium: TextStyle(color: KcColors.slate),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: KcColors.navy.withValues(alpha: 0.08)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.28"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.7"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.13.3 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: kc_app
|
||||
description: "KC-App client — multi-tenant event, election and communication platform for Konfi-Castle events."
|
||||
publish_to: 'none'
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.2
|
||||
shared_preferences: ^2.3.2
|
||||
web_socket_channel: ^3.0.1
|
||||
crypto: ^3.0.6
|
||||
web: ^1.1.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:kc_app/api.dart';
|
||||
import 'package:kc_app/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows the login screen when there is no stored token', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final state = AppState(Api(http.Client()));
|
||||
await state.bootstrap(); // no stored token -> resolves immediately, no network
|
||||
|
||||
await tester.pumpWidget(KcApp(state: state));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Konfi / Gast'), findsOneWidget);
|
||||
expect(find.text('Team-Login'), findsOneWidget);
|
||||
expect(find.text('Einladung'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Weiter'), findsWidgets);
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
@@ -0,0 +1,22 @@
|
||||
// Background handler for FCM web push. Keep the config in sync with
|
||||
// window.KC_FIREBASE in index.html (a service worker can't read window).
|
||||
// Analytics is NOT initialised here — it only makes sense on visible pages
|
||||
// with a real navigator context; the main index.html handles it.
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: 'AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M',
|
||||
projectId: 'konfi-castle-app',
|
||||
messagingSenderId: '307226979593',
|
||||
appId: '1:307226979593:web:66898b2c78b2d110ef77d8',
|
||||
});
|
||||
|
||||
firebase.messaging().onBackgroundMessage(function (payload) {
|
||||
const n = payload.notification || {};
|
||||
self.registration.showNotification(n.title || 'KC-App', {
|
||||
body: n.body || '',
|
||||
icon: '/icons/Icon-192.png',
|
||||
data: payload.data || {},
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Konfi-Castle App">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="KC-App">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>KC-App</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<!-- Firebase Cloud Messaging (web push) + Google Analytics for Firebase
|
||||
(usage analytics, visible under Firebase Console > Analytics).
|
||||
`vapidKey` is the *public* half of the Web Push certificate key pair;
|
||||
the private half stays in Firebase. Backend push sending still needs a
|
||||
service-account JSON + PUSH_PROVIDER=fcm. -->
|
||||
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-analytics-compat.js"></script>
|
||||
<script>
|
||||
window.KC_FIREBASE = {
|
||||
apiKey: "«reda...…»",
|
||||
authDomain: "konfi-castle-app.firebaseapp.com",
|
||||
projectId: "konfi-castle-app",
|
||||
storageBucket: "konfi-castle-app.firebasestorage.app",
|
||||
messagingSenderId: "307226979593",
|
||||
appId: "1:307226979593:web:66898b2c78b2d110ef77d8",
|
||||
measurementId: "G-NK8K5VV40D",
|
||||
vapidKey: "BEAHrnIzkTBGSEws1J_HRvsTtc6nqvmW4MvFMTfljmjuunqo4yiJoUcq8_jKIt_hbm4diOt0czG28l-dpvGK8T8"
|
||||
};
|
||||
// Initialise Firebase + Analytics immediately (every page load, not just
|
||||
// after login) so usage shows up in the Firebase Console. Automatically
|
||||
// logs page_view/session_start/first_visit; screen navigation inside the
|
||||
// Flutter app isn't tracked without extra instrumentation, but overall
|
||||
// reach (users, sessions, retention) is.
|
||||
(function () {
|
||||
try {
|
||||
if (!firebase.apps.length) firebase.initializeApp(window.KC_FIREBASE);
|
||||
firebase.analytics();
|
||||
} catch (e) {
|
||||
console.warn("[analytics] init failed", e);
|
||||
}
|
||||
})();
|
||||
window.kcGetPushToken = async function () {
|
||||
try {
|
||||
var cfg = window.KC_FIREBASE;
|
||||
if (!cfg.vapidKey || cfg.vapidKey === "REPLACE_ME" || !("Notification" in window)) return null;
|
||||
if (!firebase.apps.length) firebase.initializeApp(cfg);
|
||||
var permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return null;
|
||||
var registration = await navigator.serviceWorker.register("firebase-messaging-sw.js");
|
||||
var messaging = firebase.messaging();
|
||||
return await messaging.getToken({ vapidKey: cfg.vapidKey, serviceWorkerRegistration: registration });
|
||||
} catch (e) {
|
||||
console.warn("[push] init failed", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "KC-App",
|
||||
"short_name": "KC-App",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1078
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1E2032" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KC-App" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700;800&family=Mulish:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=3" />
|
||||
<title>KC-App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-root">
|
||||
|
||||
<!-- ═══════════ LOGIN SCREEN ═══════════ -->
|
||||
<section id="login-screen" class="login-wrap">
|
||||
<div class="login-logo">KC<span>-App</span></div>
|
||||
<p class="login-sub">Konfi-Castle Events · Web-Client</p>
|
||||
|
||||
<div class="login-card">
|
||||
<form id="code-form" class="form-grid">
|
||||
<label>
|
||||
<span>Code</span>
|
||||
<input id="login-code" name="code" placeholder="Einladungscode, 'LT', Gemeinde-Name, E-Mail…" autocomplete="off" autofocus />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-full">Weiter</button>
|
||||
</form>
|
||||
|
||||
<!-- Follow-up forms, shown depending on what the code resolved to -->
|
||||
<form id="guest-form" class="form-grid" hidden>
|
||||
<p class="card-hint" id="guest-form-hint">Konfi-/Gast-Zugang</p>
|
||||
<label>
|
||||
<span>Vorname</span>
|
||||
<input id="first-name" name="firstName" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Nachname</span>
|
||||
<input id="last-name" name="lastName" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-orange btn-full">Beitreten</button>
|
||||
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
|
||||
</form>
|
||||
|
||||
<form id="team-form" class="form-grid" hidden>
|
||||
<p class="card-hint" id="team-form-hint">Team-Login</p>
|
||||
<label>
|
||||
<span>Passwort</span>
|
||||
<input id="team-password" name="password" type="password" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
|
||||
</form>
|
||||
|
||||
<form id="teamer-invite-form" class="form-grid" hidden>
|
||||
<p class="card-hint" id="teamer-invite-hint">Einladung als Gemeinde-Teamer</p>
|
||||
<label>
|
||||
<span>Vorname</span>
|
||||
<input id="ti-first-name" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Nachname</span>
|
||||
<input id="ti-last-name" required />
|
||||
</label>
|
||||
<label id="ti-email-label">
|
||||
<span>E-Mail</span>
|
||||
<input id="ti-email" type="email" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Passwort (min. 8 Zeichen)</span>
|
||||
<input id="ti-password" type="password" minlength="8" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-orange btn-full">Konto erstellen</button>
|
||||
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
|
||||
</form>
|
||||
|
||||
<div id="sso-panel" class="form-grid" hidden>
|
||||
<p class="card-hint">Anmeldung mit deiner Konfi-Castle-ID (Single Sign-On).</p>
|
||||
<button type="button" id="authentik-login-btn" class="btn btn-primary btn-full">Mit Konfi-Castle-ID anmelden</button>
|
||||
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
|
||||
</div>
|
||||
|
||||
<div id="verantw-invite-panel" class="form-grid" hidden>
|
||||
<p class="card-hint" id="verantw-invite-hint">Einladung als Gemeinde-Verantwortliche/r</p>
|
||||
<p class="card-hint">Anmeldung mit deiner Konfi-Castle-ID (Single Sign-On), die Einladung wird danach automatisch eingelöst.</p>
|
||||
<button type="button" id="verantw-invite-login-btn" class="btn btn-primary btn-full">Mit Konfi-Castle-ID anmelden & einlösen</button>
|
||||
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
|
||||
</div>
|
||||
|
||||
<p id="login-status" class="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ APP SHELL ═══════════ -->
|
||||
<div id="shell" class="shell" hidden>
|
||||
<header class="hdr">
|
||||
<div>
|
||||
<div class="hdr-logo">KC<span>-App</span></div>
|
||||
<div class="hdr-sub" id="hdr-sub"> </div>
|
||||
</div>
|
||||
<div class="hdr-space"></div>
|
||||
<span id="conn-badge" class="hdr-badge">offline</span>
|
||||
<div class="hdr-avatar" id="hdr-avatar" title="Profil">?</div>
|
||||
</header>
|
||||
|
||||
<div class="content" id="content">
|
||||
|
||||
<!-- ---------- HOME TAB ---------- -->
|
||||
<section class="screen tab-screen" data-screen="home">
|
||||
<div class="hero">
|
||||
<div class="hero-kicker">Willkommen</div>
|
||||
<div class="hero-title" id="home-greeting">Hallo!</div>
|
||||
<div class="hero-chips">
|
||||
<span class="hchip" id="home-role-chip">–</span>
|
||||
<span class="hchip" id="home-kc-chip">–</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sh">Übersicht</div>
|
||||
<div class="card card-blue">
|
||||
<div class="card-header row">
|
||||
<div>
|
||||
<h2>Workshop-Wahl</h2>
|
||||
<p class="card-hint">Offene Wahlen für dich</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="home-wahl-summary" class="status">Noch nicht geladen.</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-orange">
|
||||
<div class="card-header row">
|
||||
<div>
|
||||
<h2>Meine Chats</h2>
|
||||
<p class="card-hint">Ungelesene Unterhaltungen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="home-chat-summary" class="status">Noch nicht geladen.</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-green">
|
||||
<div class="card-header row">
|
||||
<div>
|
||||
<h2>Dateien</h2>
|
||||
<p class="card-hint">Für dich freigegeben</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="home-files-summary" class="status">Noch nicht geladen.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- WAHL TAB ---------- -->
|
||||
<section class="screen tab-screen" data-screen="wahl" hidden>
|
||||
<div class="sh">Workshop-Wahl</div>
|
||||
<div id="wahl-guest-view">
|
||||
<div id="wahl-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="wahl-lt-view" hidden>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Wahl-Verwaltung (Leitungsteam)</h2>
|
||||
<p class="card-hint">Wahl-ID eingeben um Teilnehmer/Ergebnisse zu sehen.</p>
|
||||
</div>
|
||||
<div class="form-grid form-inline">
|
||||
<label>
|
||||
<span>Wahl-ID</span>
|
||||
<input id="lt-wahl-id" placeholder="Wahl-ID" />
|
||||
</label>
|
||||
<button id="lt-load-teilnehmer" class="btn btn-primary">Laden</button>
|
||||
<button id="lt-run-zuteilung" class="btn btn-orange">Zuteilung starten</button>
|
||||
</div>
|
||||
<ul id="lt-teilnehmer-list" class="list">
|
||||
<li class="list-empty">Noch nicht geladen.</li>
|
||||
</ul>
|
||||
<p id="wahl-lt-status" class="status"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- CHAT TAB ---------- -->
|
||||
<section class="screen tab-screen" data-screen="chat" hidden>
|
||||
<div class="sh">Chats</div>
|
||||
|
||||
<div id="gruppen-card" class="card card-blue" hidden>
|
||||
<div class="card-header row">
|
||||
<div>
|
||||
<h2>Gruppenchat erstellen</h2>
|
||||
<p class="card-hint">Team & Konfis frei wählen, KC-weit.</p>
|
||||
</div>
|
||||
<button id="load-candidates" class="btn btn-ghost btn-sm">Kandidaten</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>Name der Gruppe</span>
|
||||
<input id="gruppe-name" placeholder="z. B. Ausflugsplanung" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<p class="card-hint">Team-Mitglieder</p>
|
||||
<ul id="candidate-users" class="list list-picker">
|
||||
<li class="list-empty">Noch nicht geladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="card-hint">Konfis</p>
|
||||
<ul id="candidate-guests" class="list list-picker">
|
||||
<li class="list-empty">Noch nicht geladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button id="create-gruppe" class="btn btn-primary btn-full">Gruppenchat erstellen</button>
|
||||
<p id="gruppe-status" class="status"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header row">
|
||||
<h2 style="margin:0">Meine Chats</h2>
|
||||
<button id="load-channels" class="btn btn-ghost btn-sm">Aktualisieren</button>
|
||||
</div>
|
||||
<ul id="channel-list" class="list list-channels">
|
||||
<li class="list-empty">Noch keine Chats geladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="chat-thread-card" class="card" hidden>
|
||||
<div class="card-header row">
|
||||
<h2 id="chat-thread-title" style="margin:0">Chat</h2>
|
||||
<button id="manage-participants-btn" class="btn btn-ghost btn-sm" hidden>Teilnehmer</button>
|
||||
</div>
|
||||
<ul id="chat-log" class="list list-chat">
|
||||
<li class="list-empty">Noch keine Nachrichten.</li>
|
||||
</ul>
|
||||
<div class="form-grid form-inline" style="margin-top:10px">
|
||||
<label style="flex:3">
|
||||
<span>Nachricht</span>
|
||||
<input id="chat-message" placeholder="Nachricht schreiben…" />
|
||||
</label>
|
||||
<button id="send-message" class="btn btn-primary">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manage-card" class="card" hidden>
|
||||
<div class="card-header">
|
||||
<h2>Teilnehmer verwalten</h2>
|
||||
<p class="card-hint">Für den aktuell geöffneten Gruppenchat.</p>
|
||||
</div>
|
||||
<div class="form-grid form-inline">
|
||||
<label>
|
||||
<span>User-ID</span>
|
||||
<input id="manage-user-id" placeholder="optional" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Gast-ID</span>
|
||||
<input id="manage-guest-id" placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="add-participant" class="btn btn-primary btn-sm">Hinzufügen</button>
|
||||
<button id="remove-participant" class="btn btn-ghost btn-sm">Entfernen</button>
|
||||
</div>
|
||||
<p id="manage-status" class="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- FILES TAB ---------- -->
|
||||
<section class="screen tab-screen" data-screen="files" hidden>
|
||||
<div class="sh">Dateien</div>
|
||||
<div class="card">
|
||||
<div class="card-header row">
|
||||
<h2 style="margin:0">Freigegebene Dokumente</h2>
|
||||
<button id="load-files" class="btn btn-ghost btn-sm">Aktualisieren</button>
|
||||
</div>
|
||||
<ul id="file-list" class="list list-files">
|
||||
<li class="list-empty">Noch keine Dateien geladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- PROFILE TAB ---------- -->
|
||||
<section class="screen tab-screen" data-screen="profile" hidden>
|
||||
<div class="prof-hero">
|
||||
<div class="prof-av" id="profile-av">?</div>
|
||||
<div class="prof-name" id="profile-name">–</div>
|
||||
<div class="prof-role" id="profile-role">–</div>
|
||||
</div>
|
||||
|
||||
<div class="si" id="profile-kc-row">
|
||||
<span>KC-ID</span>
|
||||
<span id="profile-kc-id" class="chip chip-blue">–</span>
|
||||
</div>
|
||||
<div class="si" id="profile-server-row">
|
||||
<span>Server</span>
|
||||
<span class="chip chip-green">verbunden</span>
|
||||
</div>
|
||||
|
||||
<button id="logout-btn" class="btn btn-danger btn-full" style="margin-top:16px">Abmelden</button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<nav class="bnav">
|
||||
<button class="nb on" data-screen="home">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/></svg>
|
||||
Home
|
||||
</button>
|
||||
<button class="nb" data-screen="wahl">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Wahl
|
||||
</button>
|
||||
<button class="nb" data-screen="chat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
Chat
|
||||
</button>
|
||||
<button class="nb" data-screen="files">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg>
|
||||
Dateien
|
||||
</button>
|
||||
<button class="nb" data-screen="profile">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
Profil
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,717 @@
|
||||
:root {
|
||||
--blue: #2F7CFF;
|
||||
--blue-dark: #1F52A9;
|
||||
--orange: #F17C20;
|
||||
--orange-dark: #C8600F;
|
||||
--green: #4EBA9A;
|
||||
--green-dark: #2E7C64;
|
||||
--grey: #69768B;
|
||||
--dark: #2B2D42;
|
||||
--dark-2: #1E2032;
|
||||
--cream: #F6F7FB;
|
||||
--card: #FFFFFF;
|
||||
--card-border: #E7EAF3;
|
||||
--text: #2B2D42;
|
||||
--text-muted: #69768B;
|
||||
--danger: #E24C4C;
|
||||
--radius: 16px;
|
||||
--sh: 0 10px 24px rgba(43, 45, 66, 0.08);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The `hidden` attribute must always win over any `display` set by a class
|
||||
on the same element (e.g. `.form-grid { display: flex }` on a form that's
|
||||
also `hidden`) — otherwise every login follow-up form renders at once. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html, body, #app-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Mulish', 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: #d9deee;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
h1, h2, h3, .headline {
|
||||
font-family: 'Poppins', 'Mulish', sans-serif;
|
||||
}
|
||||
|
||||
/* ─── APP SHELL ─────────────────────────────────────────── */
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
background: var(--cream);
|
||||
box-shadow: 0 0 60px rgba(30, 32, 50, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.screen {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
|
||||
/* ─── HEADER ────────────────────────────────────────────── */
|
||||
.hdr {
|
||||
background: linear-gradient(120deg, var(--dark), var(--dark-2));
|
||||
border-bottom: 2px solid #33365a;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.hdr-logo {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hdr-logo span { color: var(--orange); }
|
||||
|
||||
.hdr-sub {
|
||||
font-size: 0.68rem;
|
||||
color: #9aa3c4;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.hdr-space { flex: 1; }
|
||||
|
||||
.hdr-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: #d7dcf2;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hdr-badge.online {
|
||||
color: #7ce7c4;
|
||||
border-color: rgba(78, 186, 154, 0.5);
|
||||
background: rgba(78, 186, 154, 0.15);
|
||||
}
|
||||
|
||||
.hdr-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--orange);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── BOTTOM NAV ────────────────────────────────────────── */
|
||||
.bnav {
|
||||
background: linear-gradient(120deg, var(--dark), var(--dark-2));
|
||||
display: flex;
|
||||
border-top: 1px solid #33365a;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.nb {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px 2px 11px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8b93b3;
|
||||
font-family: 'Mulish', sans-serif;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
gap: 3px;
|
||||
transition: all 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nb.on {
|
||||
color: var(--orange);
|
||||
background: rgba(241, 124, 32, 0.1);
|
||||
}
|
||||
|
||||
.nb svg { width: 20px; height: 20px; }
|
||||
|
||||
/* ─── HERO ──────────────────────────────────────────────── */
|
||||
.hero {
|
||||
background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%);
|
||||
border-bottom: 3px solid #262a4a;
|
||||
padding: 22px 16px 18px;
|
||||
margin: -16px -16px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -44px;
|
||||
right: -44px;
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
border-radius: 50%;
|
||||
background: var(--orange);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -70px;
|
||||
left: -30px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--orange);
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
line-height: 1.18;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-title span { color: var(--orange); }
|
||||
|
||||
.hero-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hchip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.73rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ─── CARDS ─────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--sh);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-accent { border-left: 4px solid var(--orange); }
|
||||
.card-orange { border-left: 4px solid var(--orange); }
|
||||
.card-blue { border-left: 4px solid var(--blue); }
|
||||
.card-green { border-left: 4px solid var(--green); }
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.card-header.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sh {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
color: var(--dark);
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.sh:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── FORMS ─────────────────────────────────────────────── */
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-inline label { flex: 1; min-width: 140px; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--card-border);
|
||||
background: #fbfcfe;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: 'Mulish', sans-serif;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
input::placeholder { color: #a3aac2; }
|
||||
|
||||
input:focus, select:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px rgba(47, 124, 255, 0.15);
|
||||
}
|
||||
|
||||
/* ─── BUTTONS ───────────────────────────────────────────── */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: 'Mulish', sans-serif;
|
||||
transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:active { transform: translateY(1px); }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(120deg, var(--blue), var(--blue-dark));
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(47, 124, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover { opacity: 0.92; }
|
||||
|
||||
.btn-orange {
|
||||
background: linear-gradient(120deg, var(--orange), var(--orange-dark));
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(241, 124, 32, 0.3);
|
||||
}
|
||||
|
||||
.btn-green {
|
||||
background: linear-gradient(120deg, var(--green), var(--green-dark));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(43, 45, 66, 0.05);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--card-border);
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-ghost:hover { background: rgba(43, 45, 66, 0.1); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm { padding: 0.4rem 0.7rem; font-size: 0.76rem; border-radius: 9px; }
|
||||
.btn-full { width: 100%; }
|
||||
|
||||
/* ─── STATUS TEXT ───────────────────────────────────────── */
|
||||
.status {
|
||||
min-height: 1.1rem;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status.status-ok { color: var(--green-dark); }
|
||||
.status.status-error { color: var(--danger); }
|
||||
|
||||
/* ─── LISTS ─────────────────────────────────────────────── */
|
||||
.list {
|
||||
list-style: none;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.list li {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.list li:last-child { border-bottom: none; }
|
||||
|
||||
.list-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.list-files li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-files li::before { content: "📄"; }
|
||||
.list-files li:hover { background: rgba(47, 124, 255, 0.05); }
|
||||
|
||||
.list-chat {
|
||||
background: #efeae2;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.list-chat li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-width: 80%;
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
border-radius: 0 12px 12px 12px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-chat li.own {
|
||||
align-self: flex-end;
|
||||
background: #e7ffdb;
|
||||
border-radius: 12px 0 12px 12px;
|
||||
}
|
||||
|
||||
.list-chat li .chat-meta {
|
||||
font-size: 0.68rem;
|
||||
color: #667781;
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.list-picker li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-picker li input { padding: 0; width: auto; }
|
||||
.list-picker li.picked { background: rgba(47, 124, 255, 0.08); }
|
||||
|
||||
.list-channels li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-channels li:hover { background: rgba(47, 124, 255, 0.05); }
|
||||
.list-channels li.active-channel { background: rgba(241, 124, 32, 0.08); border-left: 3px solid var(--orange); }
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-orange { background: rgba(241, 124, 32, 0.1); color: var(--orange-dark); border-color: transparent; }
|
||||
.chip-blue { background: rgba(47, 124, 255, 0.1); color: var(--blue-dark); border-color: transparent; }
|
||||
.chip-green { background: rgba(78, 186, 154, 0.15); color: var(--green-dark); border-color: transparent; }
|
||||
|
||||
/* ─── TABS ──────────────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--card-border);
|
||||
background: #fff;
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: 'Mulish', sans-serif;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: white;
|
||||
background: linear-gradient(120deg, var(--blue), var(--blue-dark));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ─── LOGIN ─────────────────────────────────────────────── */
|
||||
.login-wrap {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo span { color: var(--orange); }
|
||||
|
||||
.login-sub {
|
||||
color: #a3aad0;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 26px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
padding: 22px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ─── PROFILE ───────────────────────────────────────────── */
|
||||
.prof-hero {
|
||||
background: linear-gradient(120deg, var(--dark), var(--dark-2));
|
||||
color: white;
|
||||
border-radius: var(--radius);
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #33365a;
|
||||
}
|
||||
|
||||
.prof-av {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 50%;
|
||||
background: var(--orange);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
margin: 0 auto 8px;
|
||||
border: 3px solid var(--blue);
|
||||
}
|
||||
|
||||
.prof-name {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.prof-role {
|
||||
font-size: 0.78rem;
|
||||
color: #a3aad0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.si {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px rgba(43, 45, 66, 0.05);
|
||||
}
|
||||
|
||||
/* ─── WORKSHOP PICK ─────────────────────────────────────── */
|
||||
.workshop-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: var(--card);
|
||||
border: 1.5px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 8px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.workshop-item.picked {
|
||||
border-color: var(--blue);
|
||||
background: rgba(47, 124, 255, 0.05);
|
||||
}
|
||||
|
||||
.workshop-rank {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workshop-rank.empty {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--card-border);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* ─── EMPTY STATE ───────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 30px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 2.2rem; margin-bottom: 8px; }
|
||||
|
||||
/* ─── TOAST ─────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: absolute;
|
||||
bottom: 78px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--dark);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
z-index: 300;
|
||||
white-space: nowrap;
|
||||
animation: toastUp 0.3s;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@keyframes toastUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
}
|
||||
|
||||
.dvd { height: 1px; background: var(--card-border); margin: 14px 0; }
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.screen { padding: 14px 12px 20px; }
|
||||
}
|
||||
@@ -1,37 +1,140 @@
|
||||
# Plan: KC-App – Multi-Tenant Event-, Wahl- und Kommunikationsplattform
|
||||
|
||||
Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS + PostgreSQL + Prisma**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop).
|
||||
Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS 10 + PostgreSQL + Prisma 5**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus.
|
||||
|
||||
**Domänenmodell**
|
||||
- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel.
|
||||
- Rollen: **Leitungsteam** (global über alle KCs, Authentik-Gruppe) > **Gemeinde Verantwortliche** (pro Gemeinde/KC, Authentik, verwalten nur eigene Teamer) > **Gemeinde Teamer** (von Verantwortlichen angelegt, Authentik) > **Guest/Konfi** (optionaler lokaler Account auf dem Server, Vor-/Nachname Pflicht, temporär pro KC, kein Authentik).
|
||||
- Einstieg über KC-Code/QR: gewährt Guest-Zugang oder Vorregistrierung als Verantwortlicher/Teamer einer Gemeinde.
|
||||
- Wahlen werden vom LT pro KC angelegt (Name mit Datumsschlüssel + "Teil").
|
||||
- Dateien: Sichtbarkeitsstufen alle / alle außer Konfis / nur LT.
|
||||
- Chat: Gruppenchat pro Gemeinde, 1:1-DMs, LT-übergreifende Kanäle, Broadcast (read-only für Konfis), Push via FCM/APNs.
|
||||
- Server grundsätzlich online (Cloud); zusätzlich lokaler On-Site-Server pro Event, wird von Clients automatisch bevorzugt wenn im lokalen Netz erreichbar, ist während des Events alleinige Quelle der Wahrheit, synchronisiert danach mit Cloud (keine echten Schreibkonflikte durch dieses Design).
|
||||
> Status: **Backend-Phasen 0–6 komplett** plus lokale Teamer-Accounts/Invites, Gemeinde-CRUD, Verantwortlichen-Selbstregistrierung (`onboarding/`), Authentik-JIT + LT-aus-`groups`, `mail/`-Modul. Erste Prisma-Migration vorhanden, Backend lief Ende-zu-Ende gegen lokales PostgreSQL 16 (`npm test`: 56). **Phase 7 (Flutter) begonnen**: `client/app/` — eine Codebase, Web-Target aktiv, Login/Home/Wahl/Dateien/Chat(read-only); `flutter build web` + `flutter test` grün. Offen: Mobile/Desktop-Targets, Authentik-Auth-Code-Flow im Client, WS-Chat-Senden, LT-Admin-Screens, Push, echte Authentik/Nextcloud-Infra.
|
||||
|
||||
**Phasen** (jede unabhängig verifizierbar, Reihenfolge = Abhängigkeit; Phase 6 kann parallel zu 2–5 starten, sobald API-Verträge aus Phase 0/1 stehen)
|
||||
---
|
||||
|
||||
1. **Fundament** – Monorepo-Skeleton (backend/, client/, shared contracts), Datenmodell (KC, Gemeinde, User, Membership, Wahl, Workshop, Teilnehmer/Zuteilung, ChatChannel/Message, File+Visibility, InviteCode/QR), Authentik-OIDC-Integration + Authentik-Admin-API-Client für Provisionierung.
|
||||
2. **Multi-Tenancy & Auth** – Invite/QR-Code-Fluss (KC-Key → Guest oder Vorregistrierung), Permission-Guards je Rolle/Scope, Guest-Login (Name-Pflicht, temporär).
|
||||
3. **Workshop-Wahl-Engine** – Portierung von Wahlen/Workshops/Teilnehmer/Zuteilungslogik (inkl. Force-Zuteilung, Kapazitätsprüfung, CSV-Export) aus dem WP-Plugin; LT-Verwaltung pro KC; Konfi-Formular + Ergebnisanzeige im Client. *depends on 1–2*
|
||||
4. **Dateifreigabe** – Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 1–2, parallel mit 3*
|
||||
5. **Kommunikation** – Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 1–2, parallel mit 3–4*
|
||||
6. **Hybrid Lokal/Cloud-Server & Sync** – gleiche Backend-Software als Cloud- oder Vor-Ort-Instanz deploybar, Client-seitige Auto-Discovery des lokalen Servers, Append-only-Change-Log-Sync, lokaler Server = alleinige Quelle der Wahrheit während Live-Events. *depends on 1–5 stabil*
|
||||
7. **Flutter-Clients** – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. *iterativ parallel zu 3–6, sobald jeweilige API-Verträge stehen*
|
||||
## 1. Domänenmodell
|
||||
|
||||
- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`.
|
||||
- **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC).
|
||||
- **Rollenmodell** (Enum `Role`, Authentik-gestützt):
|
||||
- **Leitungsteam (LT)** – global über alle KCs hinweg. Wird bei **jedem** Authentik-Login aus dem `groups`-Claim des Tokens abgeglichen (Gruppenname aus `AUTHENTIK_LEITUNGSTEAM_GROUP`) und als `User.isLeitungsteam` gespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-`Membership`. Kein `Membership`-Row nötig. Fällt die Gruppenmitgliedschaft weg, ist man beim nächsten Login kein LT mehr.
|
||||
- **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
|
||||
- **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account.
|
||||
- **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
|
||||
- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). Nur für `GEMEINDE_VERANTWORTLICHER`/`GEMEINDE_TEAMER` — LT läuft über `User.isLeitungsteam` (s. o.). `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships.
|
||||
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren:
|
||||
- **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt.
|
||||
- **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`).
|
||||
- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung).
|
||||
- **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops).
|
||||
- **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original).
|
||||
- **ForceZuteilung**: manuelle LT-Override vor Algorithmus-Lauf, hat Vorrang.
|
||||
- **Zuteilung**: Ergebnis pro Teilnehmer (`workshopId` nullable = unzugeteilt, `wunschRang`, `isForced`).
|
||||
- **Datei-Sichtbarkeit** (Enum `FileVisibility`): `ALLE` / `ALLE_AUSSER_KONFIS` / `NUR_LT`. Dateien werden vom LT hochgeladen, teilbar je nach KC-übergreifend/eingeschränkt gemäß Sichtbarkeitsstufe.
|
||||
- **Chat** (Enum `ChatChannelType`): `GEMEINDE_GRUPPE`, `DIREKT` (1:1, explizite `ChatParticipant`-Zuordnung), `LT_UEBERGREIFEND`, `BROADCAST` (Konfis nur lesend).
|
||||
- **Sync-Infrastruktur**: `SyncLogEntry` (Append-only-Replikationslog: `model`, `recordId`, `operation`, `payload`, `originId`, autoincrement `sequence`) + `SyncCursor` (pro Peer: `lastPushedSequence`/`lastPulledSequence`).
|
||||
|
||||
Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.prisma).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architekturentscheidungen
|
||||
|
||||
| Bereich | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) |
|
||||
| Client | Flutter, eine Codebase Mobile/Web/Desktop — `client/app/`, aktuell nur Web-Target aktiviert | vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); `lib/` ist plattformneutral, weitere Targets per `flutter create --platforms=…` nachrüstbar |
|
||||
| Web-Auslieferung | `ServeStaticModule` liefert primär den **Flutter-Web-Build** (`client/app/build/web`) auf `:3000` aus, mit SPA-Fallback (u. a. für den OIDC-Redirect `/v1/auth/callback`); fällt auf `client/web/` zurück, falls der Build fehlt. REST-API unter `/api/*`. | Ein Origin für App + API; der registrierte Authentik-Redirect zeigt auf `http://localhost:3000/v1/auth/callback` |
|
||||
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend. Client macht **Authorization Code + PKCE (S256)** direkt gegen Authentik (`sso.konfi-castle.com`, Public Client, `oidc.dart`), Backend validiert nur das Access-Token. Issuer-Trailing-Slash wird normalisiert (beide `iss`-Schreibweisen akzeptiert). | Public Client kann kein Secret halten; PKCE genügt |
|
||||
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
|
||||
| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) |
|
||||
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
|
||||
| E-Mail | Provider-Abstraktion (`MailProvider`), Default **log-only** (kein Versand), umschaltbar auf SMTP via `MAIL_PROVIDER=smtp` (`nodemailer`) | Spiegelt das Storage-Muster; E-Mail ist best-effort und darf den Invite-Flow nie blockieren |
|
||||
| Push | Provider-Abstraktion (`PushProvider`), Default **log-only**, umschaltbar auf **FCM HTTP v1** via `PUSH_PROVIDER=fcm` (Service-Account-JWT → OAuth-Token, ohne extra Dependency). Client: Firebase-Compat-SDK im `index.html` + `firebase-messaging-sw.js`, Token via `POST /api/push/register` | Gleiches Muster wie Storage/E-Mail; Push ist best-effort und blockiert das Senden nie |
|
||||
| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS |
|
||||
| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist |
|
||||
| Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit |
|
||||
| Rollen-Scope-Guard | `RolesGuard` behandelt `LEITUNGSTEAM`-Memberships als global (kcId-Check wird übersprungen) | Spiegelt die Anforderung "LT bleibt LT auf allen KCs" direkt in der Autorisierungslogik |
|
||||
|
||||
### Bekannte Einschränkungen / offene Punkte
|
||||
- **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
|
||||
- **Push-Benachrichtigungen**: `push/`-Modul (FCM HTTP v1) + `DeviceToken`-Modell + `POST /api/push/register`/`unregister`; `ChatService.sendMessage` fächert die Nachricht best-effort an die Kanal-Zielgruppe. Default-Provider **log-only**. Für echten Versand fehlen: die Firebase-Web-Secrets (`apiKey`, `appId`, VAPID-Key) in `client/app/web/index.html` + `firebase-messaging-sw.js`, sowie backend-seitig ein Service-Account-JSON + `PUSH_PROVIDER=fcm`. APNs (natives iOS) ist nicht separat gebaut — läuft über FCM, sobald ein iOS-Target existiert.
|
||||
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
|
||||
- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren.
|
||||
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
|
||||
- **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht.
|
||||
|
||||
---
|
||||
|
||||
## 3. Umgesetzte Backend-Module (Stand: alle Phasen abgeschlossen)
|
||||
|
||||
| Modul | Kernfunktion | Wichtige Endpunkte |
|
||||
|---|---|---|
|
||||
| `prisma/` | Geteilter `PrismaClient`-Provider | – |
|
||||
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships; auch der Team-Token-Pfad geht durch `toAuthenticatedUser` (synthetische LT-`Membership` bei `isLeitungsteam`). LT-Admin-Controller (`kc`/`gemeinde`/`onboarding`/`sync`/`teamer`) akzeptieren `['authentik','team']`. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register`, `GET /api/auth/me` |
|
||||
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
|
||||
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
|
||||
| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) |
|
||||
| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite (persönliche Invites werden per `MailService` best-effort verschickt, `emailSent` im Response); nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` |
|
||||
| `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | – |
|
||||
| `push/` | Globale `PushProvider`-Abstraktion (log-only Default, FCM HTTP v1 via `PUSH_PROVIDER=fcm`); `PushService.notifyChannel` löst die Kanal-Zielgruppe auf → `DeviceToken`s → Versand, prunt ungültige Tokens; von `ChatService.sendMessage` best-effort ausgelöst | `POST /api/push/register`, `POST /api/push/unregister` |
|
||||
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `GET /api/wahl/guest/overview` + `GET /api/wahl/guest/results` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
|
||||
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
|
||||
| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` |
|
||||
| `sync/` | Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), `SyncSchedulerService` (alle 30s, wenn `SYNC_ENABLED=true`) | `POST /api/sync/ingest`, `GET /api/sync/export`, `POST /api/sync/trigger` (LT-only) |
|
||||
| `common/` | `Role`-Enum, `@Roles()`-Decorator, `RolesGuard` (KC-scoped, LT global) | – |
|
||||
| Web-Client-Hosting | `ServeStaticModule` liefert `client/web/` aus; API unter globalem Prefix `/api` | `GET /` (index.html), `/app.js`, `/style.css` |
|
||||
|
||||
Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. Tech-Stack-Stolpersteine (dokumentiert für Nachvollziehbarkeit)
|
||||
|
||||
- `npx @nestjs/cli new` mit aktuellen Defaults (Nest v12-Beta, ESM, Vitest, `@nestjs/observe`) löste einen reproduzierbaren npm-Arborist-Bug aus (`Cannot read properties of null (reading 'edgesOut')`). Workaround: `backend/package.json` wurde von Hand mit gepinnten, stabilen Versionen (Nest 10.x, Jest, CommonJS, TypeScript 5.x) erstellt statt über den CLI-Generator.
|
||||
- Bei zusätzlichen offiziellen `@nestjs/*`-Paketen (`serve-static`, `schedule`) wurden die Peer-Dependencies vor der Installation geprüft (`npm view <pkg>@<version> peerDependencies`), da die jeweils neuesten Majors bereits Nest 11/12 voraussetzen und sonst mit `ERESOLVE` fehlschlagen. Gepinnt: `@nestjs/serve-static@4.0.2`, `@nestjs/schedule@4.1.1`.
|
||||
- `multer` wurde von 1.x (bekannte CVEs) auf 2.x aktualisiert.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge)
|
||||
|
||||
1. **Fundament** – Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration. ✅
|
||||
2. **Multi-Tenancy & Auth** – Invite/QR-Code-Fluss, Permission-Guards, Guest-Login. ✅
|
||||
3. **Workshop-Wahl-Engine** – Wahlen/Workshops/Zuteilungslogik/CSV-Export. ✅
|
||||
4. **Dateifreigabe** – Storage-Abstraktion, Sichtbarkeitsstufen. ✅
|
||||
5. **Kommunikation** – Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen)
|
||||
6. **Hybrid Lokal/Cloud-Server & Sync** – Replikationslog, Scheduler, Shared-Secret-Auth. ✅
|
||||
7. **Flutter-Clients** – gemeinsame Codebase (`client/app/`, Web-Target). ✅ Login (Guest / lokaler Teamer / Invite-Redemption, Token in `shared_preferences`, `GET /auth/me` für rollenabhängige Startseite), Guest-Wahl: **Wünsche** (`/wahl/guest/overview` → geordnete Auswahl → Absenden) + **Ergebnis** (`/wahl/guest/results`, PENDING/ASSIGNED/UNASSIGNED), Datei-Liste, **Chat** mit REST-Verlauf + Live-`chat:message` und Senden über das `/chat`-WebSocket (`chat_socket.dart`). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben).
|
||||
|
||||
---
|
||||
|
||||
## 6. Relevante Referenz
|
||||
|
||||
**Relevante Referenz**
|
||||
- WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen).
|
||||
|
||||
**Verifikation**
|
||||
1. Nach Phase 1: Login-Flow testbar (LT via Authentik, Guest via KC-Code), Rechte-Guards per Integrationstests.
|
||||
2. Nach Phase 3: Zuteilungslogik mit Testdaten gegen bekannte Ergebnisse aus dem alten Plugin validieren.
|
||||
3. Nach Phase 6: Sync-Test — Änderungen am lokalen Server während simuliertem Offline-Zustand, danach Cloud-Abgleich prüfen.
|
||||
4. Ende-zu-Ende: Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) manuell in allen Kernfeatures durchspielen.
|
||||
---
|
||||
|
||||
**Entscheidungen**
|
||||
- Backend: NestJS + PostgreSQL + Prisma (bestätigt).
|
||||
- Client: Flutter, eine Codebase für Mobile/Web/Desktop (auf Wunsch des Nutzers von mir entschieden).
|
||||
- Zuteilungen-Konflikte: kein echtes Konfliktmodell nötig, da lokaler Server während Events alleinige Quelle der Wahrheit ist.
|
||||
- WP-Plugin wird vollständig abgelöst, nicht weiterverwendet (nur als fachliche Vorlage).
|
||||
## 7. Verifikation (durchgeführt je Phase)
|
||||
|
||||
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
|
||||
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
|
||||
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
|
||||
3a. Gegen lokales **PostgreSQL 16** (Homebrew): `prisma migrate dev --name init` erzeugt/appliziert die erste Migration; Guest-Flow Ende-zu-Ende geprüft (`/auth/guest` → `/auth/me` → `/wahl/guest/overview` → `POST …/teilnehmer` → Re-Fetch zeigt `meinePrioritaeten`). Seed: `prisma/seed-dev.js`.
|
||||
3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok.
|
||||
3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang).
|
||||
3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise.
|
||||
3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve` → `ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`, `PATCH /api/wahl/:id` `isOpen`, `GET /api/wahl/:id/teilnehmer`, CSV-Export); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. (Datei-Upload `POST /api/files/:kcId` liefert 500 ohne konfiguriertes Nextcloud/S3 — erwartet.)
|
||||
3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com` → `GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch).
|
||||
3g. Push: **echter FCM-Versand verifiziert** gegen `konfi-castle-app` (`PUSH_PROVIDER=fcm` + Service-Account-JSON): Chat-Nachricht → `PushService.notifyChannel` → `FcmPushProvider` → Service-Account-JWT → OAuth-Token (200) → `messages:send` erreicht die API; ein Bogus-Token bekommt `400 INVALID_ARGUMENT` und wird aus `device_token` geprunt. Client-Config komplett (`apiKey`/`appId`/`vapidKey` in `index.html` + `firebase-messaging-sw.js`). **Noch offen:** ein echter Browser muss einmal „Benachrichtigungen erlauben" und einen echten Token liefern.
|
||||
4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests):
|
||||
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
|
||||
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
|
||||
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, E-Mail-Versand nur bei persönlichem Invite + Best-effort bei Transport-Fehler, Löschung.
|
||||
- `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject.
|
||||
- `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
|
||||
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
|
||||
|
||||
---
|
||||
|
||||
## 8. Nächste Schritte
|
||||
|
||||
1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung + Ergebnis, öffnen/schließen, **Force-Zuteilung**, **CSV-Export** als Browser-Download), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**, **LT-Datei-Upload** (nativer `<input file>` + Sichtbarkeitsstufe). 🔜 Browser-OIDC-Roundtrip einmal live durchklicken; Mobile/Desktop-Targets (`flutter create --platforms=…`, Toolchains fehlen); Push.
|
||||
2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.)
|
||||
3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT.
|
||||
4. Push: ✅ konfiguriert & Backend-Versand verifiziert. Offen: echten Browser-Token einmal durchtesten (Notification-Permission → Zustellung).
|
||||
5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt).
|
||||
|
||||
Reference in New Issue
Block a user