diff --git a/client/app/.gitignore b/client/app/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/client/app/.gitignore @@ -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/ diff --git a/client/app/.metadata b/client/app/.metadata new file mode 100644 index 0000000..e731571 --- /dev/null +++ b/client/app/.metadata @@ -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' diff --git a/client/app/README.md b/client/app/README.md new file mode 100644 index 0000000..a98f1b5 --- /dev/null +++ b/client/app/README.md @@ -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`) exposes + `AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party + state-management package. diff --git a/client/app/analysis_options.yaml b/client/app/analysis_options.yaml new file mode 100644 index 0000000..3d3c734 --- /dev/null +++ b/client/app/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart new file mode 100644 index 0000000..8d70094 --- /dev/null +++ b/client/app/lib/api.dart @@ -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 memberships; + + factory Identity.fromJson(Map 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? ?? []) + .map((m) => Membership.fromJson(m as Map)) + .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 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 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 workshops; + final List? meinePrioritaeten; + + factory Wahl.fromJson(Map 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) + .map((w) => Workshop.fromJson(w as Map)) + .toList(), + meinePrioritaeten: (j['meinePrioritaeten'] as List?) + ?.map((e) => e as String) + .toList(), + ); +} + +class GuestOverview { + GuestOverview({required this.kcName, required this.wahlen}); + final String kcName; + final List wahlen; + factory GuestOverview.fromJson(Map j) => GuestOverview( + kcName: (j['kc'] as Map?)?['name'] as String? ?? '', + wahlen: (j['wahlen'] as List) + .map((w) => Wahl.fromJson(w as Map)) + .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 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 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 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 get _headers => { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + + Future _get(String path) async { + final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers); + return _decode(res); + } + + Future _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 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 teamLogin(String email, String password) async { + final j = await _post('/auth/team-login', {'email': email, 'password': password}); + return j['accessToken'] as String; + } + + Future 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 me() async => + Identity.fromJson(await _get('/auth/me') as Map); + + // --- guest Wahl --- + Future guestWahlOverview() async => + GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map); + + Future submitPrioritaeten(String wahlId, List workshopIds) async { + await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); + } + + // --- files --- + Future> files(String kcId) async { + final list = await _get('/files/$kcId') as List; + return list.map((e) => FileEntry.fromJson(e as Map)).toList(); + } + + String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId'; + + // --- chat (read-only for now; sending is a WebSocket-only path) --- + Future> channels(String kcId) async { + final list = await _get('/chat/$kcId/channels') as List; + return list.map((e) => ChatChannel.fromJson(e as Map)).toList(); + } + + Future> messages(String channelId) async { + final list = await _get('/chat/channels/$channelId/messages') as List; + return list.map((e) => ChatMessage.fromJson(e as Map)).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 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 _establish(String token) async { + _api.token = token; + _identity = await _api.me(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + notifyListeners(); + } + + Future guestLogin(String code, String first, String last) => + _api.guestLogin(code, first, last).then(_establish); + + Future teamLogin(String email, String password) => + _api.teamLogin(email, password).then(_establish); + + Future 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 logout() async { + _api.token = null; + _identity = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + notifyListeners(); + } +} diff --git a/client/app/lib/main.dart b/client/app/lib/main.dart new file mode 100644 index 0000000..811010f --- /dev/null +++ b/client/app/lib/main.dart @@ -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 { + const AppScope({super.key, required AppState state, required super.child}) + : super(notifier: state); + + static AppState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + 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(); + } +} diff --git a/client/app/lib/screens/chat_screen.dart b/client/app/lib/screens/chat_screen.dart new file mode 100644 index 0000000..63cc8e9 --- /dev/null +++ b/client/app/lib/screens/chat_screen.dart @@ -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 createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + Future>? _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>( + 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>? _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>( + 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), + ), + ), + ); + } +} diff --git a/client/app/lib/screens/files_screen.dart b/client/app/lib/screens/files_screen.dart new file mode 100644 index 0000000..a37c062 --- /dev/null +++ b/client/app/lib/screens/files_screen.dart @@ -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 createState() => _FilesScreenState(); +} + +class _FilesScreenState extends State { + Future>? _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>( + 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)}', + ), + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart new file mode 100644 index 0000000..44d7586 --- /dev/null +++ b/client/app/lib/screens/home_screen.dart @@ -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 = [ + 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 = [ + '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, + ), + ); + } +} diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart new file mode 100644 index 0000000..ab94dd3 --- /dev/null +++ b/client/app/lib/screens/login_screen.dart @@ -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 fields; + final Future Function() onSubmit; + + @override + State<_FormShell> createState() => _FormShellState(); +} + +class _FormShellState extends State<_FormShell> { + bool _busy = false; + String? _error; + + Future _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(), + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_screen.dart b/client/app/lib/screens/wahl_screen.dart new file mode 100644 index 0000000..ce6eca9 --- /dev/null +++ b/client/app/lib/screens/wahl_screen.dart @@ -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 createState() => _WahlScreenState(); +} + +class _WahlScreenState extends State { + Future? _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( + 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 _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 _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')), + ], + ), + ), + ); + } +} diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock new file mode 100644 index 0000000..0da6913 --- /dev/null +++ b/client/app/pubspec.lock @@ -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" diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml new file mode 100644 index 0000000..47a4147 --- /dev/null +++ b/client/app/pubspec.yaml @@ -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 diff --git a/client/app/test/smoke_test.dart b/client/app/test/smoke_test.dart new file mode 100644 index 0000000..ae2922a --- /dev/null +++ b/client/app/test/smoke_test.dart @@ -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); + }); +} diff --git a/client/app/web/favicon.png b/client/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/client/app/web/favicon.png differ diff --git a/client/app/web/icons/Icon-192.png b/client/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/client/app/web/icons/Icon-192.png differ diff --git a/client/app/web/icons/Icon-512.png b/client/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/client/app/web/icons/Icon-512.png differ diff --git a/client/app/web/icons/Icon-maskable-192.png b/client/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/client/app/web/icons/Icon-maskable-192.png differ diff --git a/client/app/web/icons/Icon-maskable-512.png b/client/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/client/app/web/icons/Icon-maskable-512.png differ diff --git a/client/app/web/index.html b/client/app/web/index.html new file mode 100644 index 0000000..443cec3 --- /dev/null +++ b/client/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + kc_app + + + + + + + diff --git a/client/app/web/manifest.json b/client/app/web/manifest.json new file mode 100644 index 0000000..0785abb --- /dev/null +++ b/client/app/web/manifest.json @@ -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" + } + ] +}