feat(client): Flutter app (web target) — Phase 7 start
Single Flutter codebase under client/app/ with web enabled (mobile/desktop can be added later; lib/ is platform-agnostic). Talks to the NestJS backend via a thin REST wrapper; API_BASE is a --dart-define (defaults to the local backend). Screens: - Login: Konfi/guest (invite code), local Teamer password login, Teamer invite redemption. Token persisted in shared_preferences, restored on start; GET /auth/me drives a role-aware home. - Workshop-Wahl (guests): loads /wahl/guest/overview, ordered pick of up to 3 workshops, submits to /wahl/:id/teilnehmer. - Dateien: /files/:kcId list. - Chat: channel + message list (read-only; WS send is a follow-up). State: AppState (ChangeNotifier) exposed via an InheritedNotifier (AppScope) — no third-party state package. flutter analyze clean, flutter build web --release passes, one widget smoke test. Also: interim client/web/ HTML placeholder stays as-is (per plan it is superseded by this Flutter web build). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# 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/
|
||||||
@@ -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,43 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d chrome --dart-define=API_BASE=http://localhost:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
`API_BASE` defaults to `http://localhost:3000/api` (the local NestJS
|
||||||
|
backend, which also serves the interim plain-HTML client at `/`).
|
||||||
|
|
||||||
|
## What's implemented
|
||||||
|
|
||||||
|
- **Login** (`lib/screens/login_screen.dart`) — three tabs:
|
||||||
|
- *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`.
|
||||||
|
- *Team-Login*: email + password for local Gemeinde Teamer →
|
||||||
|
`POST /auth/team-login`. (Leitungsteam / Verantwortliche use the
|
||||||
|
Authentik Authorization-Code flow, not yet wired into this client.)
|
||||||
|
- *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`.
|
||||||
|
- The token is stored via `shared_preferences` (localStorage on web) and
|
||||||
|
restored on start; `GET /auth/me` resolves the role for a role-aware home.
|
||||||
|
- **Home** (`lib/screens/home_screen.dart`) — identity card + navigation.
|
||||||
|
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — loads
|
||||||
|
`GET /wahl/guest/overview`, tap workshops in order (max 3) to set
|
||||||
|
priorities, `POST /wahl/:id/teilnehmer`.
|
||||||
|
- **Dateien** (`lib/screens/files_screen.dart`) — `GET /files/:kcId`,
|
||||||
|
filtered server-side by the caller's visibility tier.
|
||||||
|
- **Chat** (`lib/screens/chat_screen.dart`) — channel + message list
|
||||||
|
(read-only; sending is a WebSocket path, still to do).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `lib/api.dart` — `Api` (thin REST wrapper + models) and `AppState`
|
||||||
|
(`ChangeNotifier`: session, login/logout, token persistence).
|
||||||
|
- `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,355 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.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 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
|
||||||
|
id: j['id'] as String,
|
||||||
|
type: j['type'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
ChatMessage({required this.body, required this.createdAt});
|
||||||
|
final String body;
|
||||||
|
final String createdAt;
|
||||||
|
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> teamLogin(String email, String password) async {
|
||||||
|
final j = await _post('/auth/team-login', {'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});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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';
|
||||||
|
|
||||||
|
// --- chat (read-only for now; sending is a WebSocket-only path) ---
|
||||||
|
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);
|
||||||
|
final Api _api;
|
||||||
|
|
||||||
|
static const _tokenKey = 'kc_token';
|
||||||
|
|
||||||
|
Identity? _identity;
|
||||||
|
Identity? get identity => _identity;
|
||||||
|
bool _loading = true;
|
||||||
|
bool get loading => _loading;
|
||||||
|
bool get isLoggedIn => _identity != null;
|
||||||
|
|
||||||
|
Api get api => _api;
|
||||||
|
|
||||||
|
Future<void> bootstrap() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = prefs.getString(_tokenKey);
|
||||||
|
if (saved != null) {
|
||||||
|
_api.token = saved;
|
||||||
|
try {
|
||||||
|
_identity = await _api.me();
|
||||||
|
} catch (_) {
|
||||||
|
_api.token = null;
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _establish(String token) async {
|
||||||
|
_api.token = token;
|
||||||
|
_identity = await _api.me();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, token);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> guestLogin(String code, String first, String last) =>
|
||||||
|
_api.guestLogin(code, first, last).then(_establish);
|
||||||
|
|
||||||
|
Future<void> teamLogin(String email, String password) =>
|
||||||
|
_api.teamLogin(email, 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 {
|
||||||
|
_api.token = null;
|
||||||
|
_identity = null;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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: ThemeData(
|
||||||
|
colorSchemeSeed: const Color(0xFF3B5BA5),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
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,159 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
|
||||||
|
/// Read-only chat view. Sending a message is a WebSocket-only path on the
|
||||||
|
/// backend (`chat:send`); wiring that up is a follow-up.
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Chat')),
|
||||||
|
body: FutureBuilder<List<ChatChannel>>(
|
||||||
|
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 channels = snap.data!;
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
return const Center(child: Text('Keine Kanäle sichtbar.'));
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
for (final c in channels)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tag),
|
||||||
|
title: Text(_typeLabels[c.type] ?? c.type),
|
||||||
|
subtitle: Text(c.id),
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => _ChannelMessages(
|
||||||
|
channelId: c.id,
|
||||||
|
title: _typeLabels[c.type] ?? c.type,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelMessages extends StatefulWidget {
|
||||||
|
const _ChannelMessages({required this.channelId, required this.title});
|
||||||
|
final String channelId;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ChannelMessages> createState() => _ChannelMessagesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelMessagesState extends State<_ChannelMessages> {
|
||||||
|
Future<List<ChatMessage>>? _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_future ??= AppScope.of(context).api.messages(widget.channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(widget.title)),
|
||||||
|
body: FutureBuilder<List<ChatMessage>>(
|
||||||
|
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 messages = snap.data!;
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return const Center(child: Text('Noch keine Nachrichten.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: messages.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final m = messages[i];
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(m.body),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
m.createdAt,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'Senden folgt (WebSocket) — aktuell nur Lesen.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
|
import 'files_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 tiles = <Widget>[
|
||||||
|
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 & Nachrichten (lesen)',
|
||||||
|
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>[
|
||||||
|
'Rolle: ${id.roleLabel}',
|
||||||
|
if (id.email != null) 'E-Mail: ${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(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final l in lines) Text(l),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatelessWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('KC-App'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Konfi / Gast'),
|
||||||
|
Tab(text: 'Team-Login'),
|
||||||
|
Tab(text: 'Einladung'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: TabBarView(
|
||||||
|
children: const [
|
||||||
|
_GuestForm(),
|
||||||
|
_TeamForm(),
|
||||||
|
_InviteForm(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared submit-button + error handling for the three little forms.
|
||||||
|
class _FormShell extends StatefulWidget {
|
||||||
|
const _FormShell({required this.title, required this.fields, required this.onSubmit});
|
||||||
|
final String title;
|
||||||
|
final List<Widget> fields;
|
||||||
|
final Future<void> Function() onSubmit;
|
||||||
|
|
||||||
|
@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 ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...widget.fields,
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_error != null) ...[
|
||||||
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _busy ? null : _run,
|
||||||
|
child: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Weiter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
|
||||||
|
controller: c,
|
||||||
|
obscureText: obscure,
|
||||||
|
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _GuestForm extends StatefulWidget {
|
||||||
|
const _GuestForm();
|
||||||
|
@override
|
||||||
|
State<_GuestForm> createState() => _GuestFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuestFormState extends State<_GuestForm> {
|
||||||
|
final _code = TextEditingController();
|
||||||
|
final _first = TextEditingController();
|
||||||
|
final _last = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = AppScope.of(context);
|
||||||
|
return _FormShell(
|
||||||
|
title: 'Mit Einladungscode beitreten',
|
||||||
|
fields: [
|
||||||
|
_field(_code, 'Einladungscode'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_first, 'Vorname'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_last, 'Nachname'),
|
||||||
|
],
|
||||||
|
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamForm extends StatefulWidget {
|
||||||
|
const _TeamForm();
|
||||||
|
@override
|
||||||
|
State<_TeamForm> createState() => _TeamFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamFormState extends State<_TeamForm> {
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = AppScope.of(context);
|
||||||
|
return _FormShell(
|
||||||
|
title: 'Teamer:in-Login',
|
||||||
|
fields: [
|
||||||
|
_field(_email, 'E-Mail'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_password, 'Passwort', obscure: true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Leitungsteam & Gemeinde-Verantwortliche melden sich über die '
|
||||||
|
'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den '
|
||||||
|
'lokalen Teamer-Login ab.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSubmit: () => state.teamLogin(_email.text.trim(), _password.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _FormShell(
|
||||||
|
title: 'Teamer:in-Einladung einlösen',
|
||||||
|
fields: [
|
||||||
|
_field(_token, 'Einladungscode / Token'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_first, 'Vorname'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_last, 'Nachname'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_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,223 @@
|
|||||||
|
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 Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Workshop-Wahl')),
|
||||||
|
body: 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,362 @@
|
|||||||
|
# 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"
|
||||||
|
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: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
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,21 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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 |
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,46 @@
|
|||||||
|
<!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="A new Flutter project.">
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
</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": "A new Flutter project.",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user