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,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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user