diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 3f7f59e..453b1ec 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -370,20 +370,49 @@ class FileEntry { } class ChatChannel { - ChatChannel({required this.id, required this.type}); + ChatChannel({ + required this.id, + required this.type, + this.name, + this.gemeindeId, + this.createdByUserId, + }); final String id; final String type; + final String? name; + final String? gemeindeId; + final String? createdByUserId; + factory ChatChannel.fromJson(Map j) => ChatChannel( id: j['id'] as String, type: j['type'] as String? ?? '', + name: j['name'] as String?, + gemeindeId: j['gemeindeId'] as String?, + createdByUserId: j['createdByUserId'] as String?, ); } class ChatMessage { - ChatMessage({required this.body, required this.createdAt}); + ChatMessage({ + this.id, + this.channelId, + this.senderUserId, + this.senderGuestId, + required this.body, + required this.createdAt, + }); + final String? id; + final String? channelId; + final String? senderUserId; + final String? senderGuestId; final String body; final String createdAt; + factory ChatMessage.fromJson(Map j) => ChatMessage( + id: j['id'] as String?, + channelId: j['channelId'] as String?, + senderUserId: j['senderUserId'] as String?, + senderGuestId: j['senderGuestId'] as String?, body: j['body'] as String? ?? '', createdAt: j['createdAt'] as String? ?? '', ); diff --git a/client/app/lib/screens/chat_screen.dart b/client/app/lib/screens/chat_screen.dart index 33c795e..3696bcb 100644 --- a/client/app/lib/screens/chat_screen.dart +++ b/client/app/lib/screens/chat_screen.dart @@ -1,12 +1,28 @@ +import 'dart:math' as math; import 'package:flutter/material.dart'; import '../api.dart'; import '../chat_socket.dart'; import '../main.dart'; -/// Chat: channel list (REST) + a per-channel view that loads history over -/// REST and then streams live messages over the `/chat` WebSocket gateway -/// (`chat:join` / `chat:send` / `chat:message`). +/// WhatsApp brand colors +abstract final class WhatsAppColors { + static const primary = Color(0xFF008069); // WhatsApp Header Green + static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green + static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green + static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG + static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble + static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble + static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color + static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color + static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check + static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG + static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text + static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG + static const iconMuted = Color(0xFF54656F); +} + +/// Chat channel overview screen with WhatsApp look and feel. class ChatScreen extends StatefulWidget { const ChatScreen({super.key, required this.kcId}); final String kcId; @@ -29,58 +45,181 @@ class _ChatScreenState extends State { 'DIREKT': 'Direktnachricht', 'LT_UEBERGREIFEND': 'Leitungsteam', 'BROADCAST': 'Ankündigungen', + 'GRUPPE': 'Gruppenchat', + }; + + static const _typeIcons = { + 'GEMEINDE_GRUPPE': Icons.people_alt_rounded, + 'DIREKT': Icons.person_rounded, + 'LT_UEBERGREIFEND': Icons.shield_rounded, + 'BROADCAST': Icons.campaign_rounded, + 'GRUPPE': Icons.groups_rounded, + }; + + static const _typeColors = { + 'GEMEINDE_GRUPPE': Color(0xFF008069), + 'DIREKT': Color(0xFF2F7CFF), + 'LT_UEBERGREIFEND': Color(0xFF6C5CE7), + 'BROADCAST': Color(0xFFF17C20), + 'GRUPPE': Color(0xFF0984E3), }; @override Widget build(BuildContext context) { - return 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), + return Theme( + data: Theme.of(context).copyWith( + appBarTheme: const AppBarTheme( + backgroundColor: WhatsAppColors.primary, + foregroundColor: Colors.white, + elevation: 1, + iconTheme: IconThemeData(color: Colors.white), + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('Chats'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Suchen', + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Optionen', + onPressed: () {}, + ), + ], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(color: WhatsAppColors.primary), + ); + } + if (snap.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + const SizedBox(height: 12), + Text('${snap.error}', textAlign: TextAlign.center), + ], + ), + ), + ); + } + final channels = snap.data!; + if (channels.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + 'Keine Kanäle sichtbar', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } + return ListView.separated( + itemCount: channels.length, + separatorBuilder: (_, __) => const Divider( + indent: 80, + endIndent: 16, + height: 1, + thickness: 0.8, + color: Color(0xFFF0F2F5), ), - ); - } - 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), + itemBuilder: (context, i) { + final c = channels[i]; + final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type); + final icon = _typeIcons[c.type] ?? Icons.chat_rounded; + final color = _typeColors[c.type] ?? WhatsAppColors.primary; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: CircleAvatar( + radius: 25, + backgroundColor: color.withValues(alpha: 0.15), + child: Icon(icon, color: color, size: 28), + ), + title: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: WhatsAppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: Text( + _typeLabels[c.type] ?? c.type, + style: const TextStyle( + fontSize: 13.5, + color: WhatsAppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => _ChannelMessages( channelId: c.id, - title: _typeLabels[c.type] ?? c.type, + title: label, + subtitle: _typeLabels[c.type] ?? c.type, + icon: icon, + iconColor: color, ), ), ), - ), - ], - ); - }, + ); + }, + ); + }, + ), ), ); } } +/// WhatsApp-style chat message screen. class _ChannelMessages extends StatefulWidget { - const _ChannelMessages({required this.channelId, required this.title}); + const _ChannelMessages({ + required this.channelId, + required this.title, + required this.subtitle, + required this.icon, + required this.iconColor, + }); + final String channelId; final String title; + final String subtitle; + final IconData icon; + final Color iconColor; @override State<_ChannelMessages> createState() => _ChannelMessagesState(); @@ -94,6 +233,18 @@ class _ChannelMessagesState extends State<_ChannelMessages> { bool _loading = true; String? _loadError; String _wsStatus = 'verbinde…'; + bool _hasText = false; + + @override + void initState() { + super.initState(); + _composer.addListener(() { + final hasText = _composer.text.trim().isNotEmpty; + if (hasText != _hasText) { + setState(() => _hasText = hasText); + } + }); + } @override void didChangeDependencies() { @@ -132,7 +283,11 @@ class _ChannelMessagesState extends State<_ChannelMessages> { void _jump() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scroll.hasClients) { - _scroll.jumpTo(_scroll.position.maxScrollExtent); + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); } }); } @@ -152,85 +307,543 @@ class _ChannelMessagesState extends State<_ChannelMessages> { super.dispose(); } + String _formatTime(String rawDate) { + final dt = DateTime.tryParse(rawDate)?.toLocal(); + if (dt == null) return rawDate; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDateHeader(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final messageDate = DateTime(date.year, date.month, date.day); + + if (messageDate == today) { + return 'HEUTE'; + } else if (messageDate == today.subtract(const Duration(days: 1))) { + return 'GESTERN'; + } else { + final d = date.day.toString().padLeft(2, '0'); + final m = date.month.toString().padLeft(2, '0'); + return '$d.$m.${date.year}'; + } + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + Color _getSenderColor(String id) { + final colors = [ + const Color(0xFF1E88E5), + const Color(0xFFE53935), + const Color(0xFF8E24AA), + const Color(0xFF3949AB), + const Color(0xFF00897B), + const Color(0xFFD81B60), + const Color(0xFFFB8C00), + const Color(0xFF43A047), + ]; + var hash = 0; + for (var i = 0; i < id.length; i++) { + hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF; + } + return colors[hash % colors.length]; + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(18), - child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)), + final identity = AppScope.of(context).identity; + + return Theme( + data: Theme.of(context).copyWith( + appBarTheme: const AppBarTheme( + backgroundColor: WhatsAppColors.primary, + foregroundColor: Colors.white, + elevation: 1, + iconTheme: IconThemeData(color: Colors.white), + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), ), - body: Column( - children: [ - Expanded(child: _body(context)), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _composer, - onSubmitted: (_) => _send(), - decoration: const InputDecoration( - hintText: 'Nachricht…', - border: OutlineInputBorder(), - isDense: true, + child: Scaffold( + backgroundColor: WhatsAppColors.chatBackground, + appBar: AppBar( + titleSpacing: 0, + title: Row( + children: [ + CircleAvatar( + radius: 19, + backgroundColor: Colors.white.withValues(alpha: 0.2), + child: Icon(widget.icon, color: Colors.white, size: 22), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.title, + style: const TextStyle( + fontSize: 16.5, + fontWeight: FontWeight.w600, + color: Colors.white, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - IconButton(icon: const Icon(Icons.send), onPressed: _send), - ], + const SizedBox(height: 1), + Text( + _wsStatus == 'verbunden' || _wsStatus == 'connected' + ? 'online' + : _wsStatus, + style: TextStyle( + fontSize: 11.5, + color: Colors.white.withValues(alpha: 0.85), + fontWeight: FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Videoanruf', + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.call_rounded), + tooltip: 'Anruf', + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Optionen', + onPressed: () {}, + ), + ], + ), + body: Stack( + children: [ + // WhatsApp Doodle Pattern Background + Positioned.fill( + child: CustomPaint( + painter: _WhatsAppDoodlePainter(), ), ), - ), - ], + // Chat Content + Column( + children: [ + Expanded(child: _buildMessagesList(context, identity)), + _buildComposer(context), + ], + ), + ], + ), ), ); } - Widget _body(BuildContext context) { - if (_loading) return const Center(child: CircularProgressIndicator()); + Widget _buildMessagesList(BuildContext context, Identity? identity) { + if (_loading) { + return const Center( + child: CircularProgressIndicator(color: WhatsAppColors.primary), + ); + } if (_loadError != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), - child: Text(_loadError!, textAlign: TextAlign.center), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + const SizedBox(height: 12), + Text(_loadError!, textAlign: TextAlign.center), + ], + ), ), ); } if (_messages.isEmpty) { - return const Center(child: Text('Noch keine Nachrichten.')); - } - return ListView.builder( - controller: _scroll, - 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), + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: WhatsAppColors.dateBadgeBg, + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 3, + offset: Offset(0, 1), + ), + ], + ), + child: const Text( + 'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: WhatsAppColors.dateBadgeText, + fontWeight: FontWeight.w500, ), + ), + ), + ); + } + + final items = []; + DateTime? lastDate; + + for (var i = 0; i < _messages.length; i++) { + final m = _messages[i]; + final msgDate = DateTime.tryParse(m.createdAt)?.toLocal(); + + // Insert date divider if day changed + if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) { + items.add(_buildDateHeader(_formatDateHeader(msgDate))); + lastDate = msgDate; + } + + final isMe = (identity?.kind == SessionKind.user && + m.senderUserId != null && + m.senderUserId == identity?.userId) || + (identity?.kind == SessionKind.guest && + m.senderGuestId != null && + m.senderGuestId == identity?.guestId); + + items.add(_buildMessageBubble(m, isMe)); + } + + return ListView( + controller: _scroll, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + children: items, + ); + } + + Widget _buildDateHeader(String text) { + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: WhatsAppColors.dateBadgeBg, + borderRadius: BorderRadius.circular(7.5), + boxShadow: const [ + BoxShadow( + color: Color(0x10000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: Text( + text, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + color: WhatsAppColors.dateBadgeText, + ), + ), + ), + ); + } + + Widget _buildMessageBubble(ChatMessage m, bool isMe) { + final timeStr = _formatTime(m.createdAt); + final senderId = m.senderUserId ?? m.senderGuestId; + final showSender = !isMe && senderId != null; + final senderColor = showSender ? _getSenderColor(senderId) : Colors.black; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + minWidth: 80, + ), + child: Container( + margin: const EdgeInsets.only(bottom: 4, top: 2), + decoration: BoxDecoration( + color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(12), + topRight: const Radius.circular(12), + bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2), + bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12), + ), + boxShadow: const [ + BoxShadow( + color: Color(0x18000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Text(m.body), - const SizedBox(height: 2), - Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall), + if (showSender) ...[ + Text( + m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung', + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: senderColor, + ), + ), + const SizedBox(height: 2), + ], + Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.bottom, + spacing: 8, + runSpacing: 2, + children: [ + Text( + m.body, + style: const TextStyle( + fontSize: 15, + color: WhatsAppColors.textPrimary, + height: 1.25, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + timeStr, + style: const TextStyle( + fontSize: 10.5, + color: WhatsAppColors.textSecondary, + ), + ), + if (isMe) ...[ + const SizedBox(width: 3), + const Icon( + Icons.done_all_rounded, + size: 15, + color: WhatsAppColors.checkmarkBlue, + ), + ], + ], + ), + ), + ], + ), ], ), ), - ); - }, + ), + ), + ); + } + + Widget _buildComposer(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 3, + offset: Offset(0, 1), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.emoji_emotions_outlined), + color: WhatsAppColors.iconMuted, + splashRadius: 20, + onPressed: () {}, + ), + Expanded( + child: TextField( + controller: _composer, + minLines: 1, + maxLines: 5, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + hintText: 'Nachricht', + hintStyle: TextStyle( + color: WhatsAppColors.textSecondary, + fontSize: 15.5, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4), + ), + onSubmitted: (_) => _send(), + ), + ), + IconButton( + icon: const Icon(Icons.attach_file_rounded), + color: WhatsAppColors.iconMuted, + splashRadius: 20, + onPressed: () {}, + ), + if (!_hasText) + IconButton( + icon: const Icon(Icons.camera_alt_rounded), + color: WhatsAppColors.iconMuted, + splashRadius: 20, + onPressed: () {}, + ), + ], + ), + ), + ), + const SizedBox(width: 6), + Container( + decoration: const BoxDecoration( + color: WhatsAppColors.accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x28000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded), + color: Colors.white, + splashRadius: 24, + onPressed: _send, + ), + ), + ], + ), + ), ); } } + +/// Custom painter for the iconic subtle WhatsApp background doodle canvas. +class _WhatsAppDoodlePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFF4A6B82).withValues(alpha: 0.05) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.3; + + final fillPaint = Paint() + ..color = const Color(0xFF4A6B82).withValues(alpha: 0.035) + ..style = PaintingStyle.fill; + + const spacing = 75.0; + final rows = (size.height / spacing).ceil() + 1; + final cols = (size.width / spacing).ceil() + 1; + + for (var r = 0; r < rows; r++) { + for (var c = 0; c < cols; c++) { + final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0); + final y = r * spacing; + final type = (r * 7 + c * 11) % 6; + + canvas.save(); + canvas.translate(x, y); + + switch (type) { + case 0: // Chat bubble doodle + final rrect = RRect.fromRectAndRadius( + const Rect.fromLTWH(-10, -8, 20, 16), + const Radius.circular(5), + ); + canvas.drawRRect(rrect, fillPaint); + canvas.drawRRect(rrect, paint); + break; + case 1: // Small Star + final path = Path(); + for (var i = 0; i < 5; i++) { + final angle = i * 4 * math.pi / 5 - math.pi / 2; + final px = 8 * math.cos(angle); + final py = 8 * math.sin(angle); + if (i == 0) { + path.moveTo(px, py); + } else { + path.lineTo(px, py); + } + } + path.close(); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, paint); + break; + case 2: // Heart doodle + final path = Path(); + path.moveTo(0, 4); + path.cubicTo(-6, -2, -10, -8, 0, -10); + path.cubicTo(10, -8, 6, -2, 0, 4); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, paint); + break; + case 3: // Musical note + canvas.drawCircle(const Offset(-4, 4), 3, fillPaint); + canvas.drawCircle(const Offset(-4, 4), 3, paint); + canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint); + canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint); + break; + case 4: // Coffee / cup + final rrect = RRect.fromRectAndRadius( + const Rect.fromLTWH(-7, -5, 14, 12), + const Radius.circular(3), + ); + canvas.drawRRect(rrect, fillPaint); + canvas.drawRRect(rrect, paint); + canvas.drawArc( + const Rect.fromLTWH(4, -3, 6, 6), + -math.pi / 2, + math.pi, + false, + paint, + ); + break; + case 5: // Clock / circle + canvas.drawCircle(Offset.zero, 7, fillPaint); + canvas.drawCircle(Offset.zero, 7, paint); + canvas.drawLine(Offset.zero, const Offset(0, -4), paint); + canvas.drawLine(Offset.zero, const Offset(3, 0), paint); + break; + } + + canvas.restore(); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart index 44c7add..fc9c2d7 100644 --- a/client/app/lib/screens/login_screen.dart +++ b/client/app/lib/screens/login_screen.dart @@ -172,14 +172,19 @@ class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection bool get _isLeitungsteamCode { final c = _code.text.trim(); - return c.length > 2 && c.toUpperCase().endsWith('LT'); + final upper = c.toUpperCase(); + final lower = c.toLowerCase(); + return upper == 'LT' || (c.length > 2 && upper.endsWith('LT')) || lower == 'login' || lower == 'sso'; } /// The KC-Code with a trailing "LT" trigger stripped back off, so /// "ABC123LT" still resolves to the real invite code "ABC123". String get _plainCode { final c = _code.text.trim(); - return _isLeitungsteamCode ? c.substring(0, c.length - 2) : c; + if (c.toUpperCase() == 'LT' || c.toLowerCase() == 'login' || c.toLowerCase() == 'sso') return ''; + return (c.length > 2 && c.toUpperCase().endsWith('LT')) + ? c.substring(0, c.length - 2) + : c; } @override @@ -196,8 +201,8 @@ class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection ), const SizedBox(height: 4), Text( - 'Konfi: gib deinen KC-Code ein. Leitungsteam: hänge "LT" an den ' - 'Code an (z. B. "ABC123LT").', + 'Konfi: gib deinen KC-Code ein. Leitungsteam: gib "LT" ein oder hänge "LT" an den ' + 'Code an (z. B. "LT" oder "ABC123LT").', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), diff --git a/client/web/app.js b/client/web/app.js index 299bbea..230d356 100644 --- a/client/web/app.js +++ b/client/web/app.js @@ -1,14 +1,139 @@ +/// KC-App Web-Client — App-Shell im JKT-Stil, Konfi-Castle-Farben. +/// Vanilla JS, kein Build-Step; wird direkt vom NestJS-Backend ausgeliefert. + const state = { token: null, kcId: null, whoami: null, socket: null, activeChannelId: null, + activeChannelType: null, + channels: [], pickedUsers: new Set(), pickedGuests: new Set(), + currentScreen: 'home', }; +/// ═══════════ Authentik OIDC (Authorization Code + PKCE) ═══════════ +/// Mirrors the Flutter client's oidc.dart: public client, PKCE, no secret. +/// Defaults match the production Authentik app; override via window.__KC_OIDC__ +/// (e.g. set in a small inline + diff --git a/client/web/style.css b/client/web/style.css index 704b9d1..4706448 100644 --- a/client/web/style.css +++ b/client/web/style.css @@ -1,127 +1,269 @@ :root { - --bg: #0f1220; - --bg-soft: #171b2e; - --card: #1c2138; - --card-border: #2a3050; - --text: #eef0fb; - --text-muted: #9aa1c4; - --accent: #6c8cff; - --accent-strong: #8f6cff; - --accent-text: #ffffff; - --success: #4ade80; - --danger: #f87171; - --radius: 14px; - color-scheme: dark; + --blue: #2F7CFF; + --blue-dark: #1F52A9; + --orange: #F17C20; + --orange-dark: #C8600F; + --green: #4EBA9A; + --green-dark: #2E7C64; + --grey: #69768B; + --dark: #2B2D42; + --dark-2: #1E2032; + --cream: #F6F7FB; + --card: #FFFFFF; + --card-border: #E7EAF3; + --text: #2B2D42; + --text-muted: #69768B; + --danger: #E24C4C; + --radius: 16px; + --sh: 0 10px 24px rgba(43, 45, 66, 0.08); + color-scheme: light; } * { box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* The `hidden` attribute must always win over any `display` set by a class + on the same element (e.g. `.form-grid { display: flex }` on a form that's + also `hidden`) — otherwise every login follow-up form renders at once. */ +[hidden] { + display: none !important; +} + +html, body, #app-root { + height: 100%; } body { - margin: 0; - font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; - background: var(--bg); + font-family: 'Mulish', 'Inter', system-ui, -apple-system, sans-serif; + background: #d9deee; color: var(--text); - min-height: 100vh; - line-height: 1.5; + overflow: hidden; + overscroll-behavior: none; + -webkit-tap-highlight-color: transparent; } -.bg-decor { - position: fixed; - inset: 0; - z-index: -1; - background: - radial-gradient(600px circle at 15% -10%, rgba(108, 140, 255, 0.25), transparent 60%), - radial-gradient(500px circle at 100% 10%, rgba(143, 108, 255, 0.18), transparent 55%), - var(--bg); +h1, h2, h3, .headline { + font-family: 'Poppins', 'Mulish', sans-serif; } -.topbar { +/* ─── APP SHELL ─────────────────────────────────────────── */ +.shell { display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - max-width: 640px; + flex-direction: column; + min-height: 100dvh; + height: 100dvh; + max-width: 440px; margin: 0 auto; - padding: 2rem 1.25rem 1rem; + background: var(--cream); + box-shadow: 0 0 60px rgba(30, 32, 50, 0.25); + position: relative; + overflow: hidden; } -.brand { +.content { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding-bottom: env(safe-area-inset-bottom); +} + +.screen { + padding: 16px 16px 24px; +} + +/* ─── HEADER ────────────────────────────────────────────── */ +.hdr { + background: linear-gradient(120deg, var(--dark), var(--dark-2)); + border-bottom: 2px solid #33365a; + padding: 14px 16px; display: flex; align-items: center; - gap: 0.75rem; + gap: 10px; + position: sticky; + top: 0; + z-index: 50; } -.brand-mark { - display: grid; - place-items: center; - width: 44px; - height: 44px; - border-radius: 12px; - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); +.hdr-logo { + font-family: 'Poppins', sans-serif; + font-size: 1.1rem; + font-weight: 800; color: white; + letter-spacing: -0.02em; +} + +.hdr-logo span { color: var(--orange); } + +.hdr-sub { + font-size: 0.68rem; + color: #9aa3c4; + margin-top: 1px; +} + +.hdr-space { flex: 1; } + +.hdr-badge { + font-size: 0.68rem; font-weight: 700; - font-size: 0.95rem; - letter-spacing: 0.02em; - box-shadow: 0 8px 20px rgba(108, 140, 255, 0.35); -} - -.brand h1 { - margin: 0; - font-size: 1.25rem; - font-weight: 700; -} - -.subtitle { - margin: 0.1rem 0 0; - color: var(--text-muted); - font-size: 0.85rem; -} - -.badge { - font-size: 0.75rem; - padding: 0.35rem 0.7rem; + padding: 0.3rem 0.65rem; border-radius: 999px; - border: 1px solid var(--card-border); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #d7dcf2; + background: rgba(255, 255, 255, 0.06); white-space: nowrap; } -.badge-muted { - color: var(--text-muted); - background: rgba(255, 255, 255, 0.03); +.hdr-badge.online { + color: #7ce7c4; + border-color: rgba(78, 186, 154, 0.5); + background: rgba(78, 186, 154, 0.15); } -.badge-online { - color: var(--success); - background: rgba(74, 222, 128, 0.12); - border-color: rgba(74, 222, 128, 0.35); +.hdr-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--orange); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.85rem; + color: white; + cursor: pointer; + border: 2px solid var(--blue); + flex-shrink: 0; } -main { - max-width: 640px; - margin: 0 auto; - padding: 0.5rem 1.25rem 3rem; +/* ─── BOTTOM NAV ────────────────────────────────────────── */ +.bnav { + background: linear-gradient(120deg, var(--dark), var(--dark-2)); + display: flex; + border-top: 1px solid #33365a; + position: sticky; + bottom: 0; + z-index: 50; } -.stack { +.nb { + flex: 1; display: flex; flex-direction: column; - gap: 1.25rem; + align-items: center; + justify-content: center; + padding: 9px 2px 11px; + border: none; + background: transparent; + color: #8b93b3; + font-family: 'Mulish', sans-serif; + font-size: 0.64rem; + font-weight: 700; + cursor: pointer; + gap: 3px; + transition: all 0.15s; + min-width: 0; } +.nb.on { + color: var(--orange); + background: rgba(241, 124, 32, 0.1); +} + +.nb svg { width: 20px; height: 20px; } + +/* ─── HERO ──────────────────────────────────────────────── */ +.hero { + background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%); + border-bottom: 3px solid #262a4a; + padding: 22px 16px 18px; + margin: -16px -16px 16px; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + top: -44px; + right: -44px; + width: 190px; + height: 190px; + border-radius: 50%; + background: var(--orange); + opacity: 0.18; +} + +.hero::after { + content: ''; + position: absolute; + bottom: -70px; + left: -30px; + width: 160px; + height: 160px; + border-radius: 50%; + background: var(--green); + opacity: 0.14; +} + +.hero-kicker { + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--orange); + margin-bottom: 6px; + position: relative; +} + +.hero-title { + font-family: 'Poppins', sans-serif; + font-size: 1.5rem; + font-weight: 800; + color: white; + line-height: 1.18; + margin-bottom: 10px; + position: relative; +} + +.hero-title span { color: var(--orange); } + +.hero-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + position: relative; +} + +.hchip { + display: flex; + align-items: center; + gap: 5px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 20px; + padding: 4px 12px; + font-size: 0.73rem; + color: white; +} + +/* ─── CARDS ─────────────────────────────────────────────── */ .card { background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius); - padding: 1.5rem; - margin-bottom: 1.25rem; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + box-shadow: var(--sh); + padding: 16px; + margin-bottom: 12px; } -.stack .card { - margin-bottom: 0; -} +.card-accent { border-left: 4px solid var(--orange); } +.card-orange { border-left: 4px solid var(--orange); } +.card-blue { border-left: 4px solid var(--blue); } +.card-green { border-left: 4px solid var(--green); } .card-header { margin-bottom: 1.1rem; @@ -136,16 +278,27 @@ main { .card-header h2 { margin: 0 0 0.25rem; - font-size: 1.05rem; - font-weight: 650; + font-size: 1rem; + font-weight: 700; } .card-hint { margin: 0; color: var(--text-muted); - font-size: 0.85rem; + font-size: 0.82rem; } +.sh { + font-family: 'Poppins', sans-serif; + font-size: 0.92rem; + font-weight: 800; + color: var(--dark); + margin: 18px 0 10px; +} + +.sh:first-child { margin-top: 0; } + +/* ─── FORMS ─────────────────────────────────────────────── */ .form-grid { display: flex; flex-direction: column; @@ -158,111 +311,117 @@ main { flex-wrap: wrap; } -.form-inline label { - flex: 1; - min-width: 160px; -} +.form-inline label { flex: 1; min-width: 140px; } label { display: flex; flex-direction: column; gap: 0.35rem; - font-size: 0.82rem; + font-size: 0.78rem; color: var(--text-muted); + font-weight: 600; } -input { +input, select { + width: 100%; padding: 0.65rem 0.75rem; - font-size: 0.95rem; + font-size: 0.9rem; border-radius: 10px; - border: 1px solid var(--card-border); - background: var(--bg-soft); + border: 1.5px solid var(--card-border); + background: #fbfcfe; color: var(--text); outline: none; + font-family: 'Mulish', sans-serif; transition: border-color 0.15s ease, box-shadow 0.15s ease; } -input::placeholder { - color: #6b7194; -} +input::placeholder { color: #a3aac2; } -input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(108, 140, 255, 0.2); +input:focus, select:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(47, 124, 255, 0.15); } +/* ─── BUTTONS ───────────────────────────────────────────── */ .btn { appearance: none; border: none; - border-radius: 10px; + border-radius: 12px; padding: 0.7rem 1.1rem; - font-size: 0.92rem; - font-weight: 600; + font-size: 0.88rem; + font-weight: 700; cursor: pointer; + font-family: 'Mulish', sans-serif; transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease; } -.btn:active { - transform: translateY(1px); -} +.btn:active { transform: translateY(1px); } .btn-primary { - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - color: var(--accent-text); - box-shadow: 0 8px 20px rgba(108, 140, 255, 0.3); + background: linear-gradient(120deg, var(--blue), var(--blue-dark)); + color: white; + box-shadow: 0 8px 20px rgba(47, 124, 255, 0.3); } -.btn-primary:hover { - opacity: 0.92; +.btn-primary:hover { opacity: 0.92; } + +.btn-orange { + background: linear-gradient(120deg, var(--orange), var(--orange-dark)); + color: white; + box-shadow: 0 8px 20px rgba(241, 124, 32, 0.3); +} + +.btn-green { + background: linear-gradient(120deg, var(--green), var(--green-dark)); + color: white; } .btn-ghost { - background: rgba(255, 255, 255, 0.04); + background: rgba(43, 45, 66, 0.05); color: var(--text); border: 1px solid var(--card-border); padding: 0.5rem 0.85rem; - font-size: 0.82rem; + font-size: 0.8rem; } -.btn-ghost:hover { - background: rgba(255, 255, 255, 0.08); +.btn-ghost:hover { background: rgba(43, 45, 66, 0.1); } + +.btn-danger { + background: var(--danger); + color: white; } +.btn-sm { padding: 0.4rem 0.7rem; font-size: 0.76rem; border-radius: 9px; } +.btn-full { width: 100%; } + +/* ─── STATUS TEXT ───────────────────────────────────────── */ .status { min-height: 1.1rem; margin: 0.75rem 0 0; - font-size: 0.85rem; + font-size: 0.82rem; color: var(--text-muted); } -.status.status-ok { - color: var(--success); -} - -.status.status-error { - color: var(--danger); -} +.status.status-ok { color: var(--green-dark); } +.status.status-error { color: var(--danger); } +/* ─── LISTS ─────────────────────────────────────────────── */ .list { list-style: none; - margin: 0; - padding: 0; border: 1px solid var(--card-border); - border-radius: 10px; - max-height: 220px; + border-radius: 12px; + max-height: 240px; overflow-y: auto; - background: var(--bg-soft); + background: #fbfcfe; } .list li { - padding: 0.6rem 0.85rem; + padding: 0.65rem 0.85rem; border-bottom: 1px solid var(--card-border); - font-size: 0.88rem; + font-size: 0.86rem; } -.list li:last-child { - border-bottom: none; -} +.list li:last-child { border-bottom: none; } .list-empty { color: var(--text-muted); @@ -272,61 +431,49 @@ input:focus { .list-files li { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.6rem; + cursor: pointer; } -.list-files li::before { - content: "📄"; +.list-files li::before { content: "📄"; } +.list-files li:hover { background: rgba(47, 124, 255, 0.05); } + +.list-chat { + background: #efeae2; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 420px; + overflow-y: auto; + border: 1px solid var(--card-border); } .list-chat li { display: flex; flex-direction: column; gap: 0.15rem; + max-width: 80%; + align-self: flex-start; + background: #ffffff; + border-radius: 0 12px 12px 12px; + padding: 8px 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + border-bottom: none; } -.list-chat li .chat-body { - color: var(--text); +.list-chat li.own { + align-self: flex-end; + background: #e7ffdb; + border-radius: 12px 0 12px 12px; } -.tabs { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.tab-btn { - appearance: none; - border: 1px solid var(--card-border); - background: rgba(255, 255, 255, 0.03); - color: var(--text-muted); - border-radius: 999px; - padding: 0.4rem 0.9rem; - font-size: 0.82rem; - cursor: pointer; -} - -.tab-btn.active { - color: var(--accent-text); - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - border-color: transparent; -} - -.two-col { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - margin: 0.75rem 0 1rem; -} - -@media (max-width: 480px) { - .two-col { - grid-template-columns: 1fr; - } -} - -.list-picker { - max-height: 180px; +.list-chat li .chat-meta { + font-size: 0.68rem; + color: #667781; + text-align: right; + margin-top: 2px; } .list-picker li { @@ -336,14 +483,8 @@ input:focus { cursor: pointer; } -.list-picker li input { - padding: 0; - width: auto; -} - -.list-picker li.picked { - background: rgba(108, 140, 255, 0.12); -} +.list-picker li input { padding: 0; width: auto; } +.list-picker li.picked { background: rgba(47, 124, 255, 0.08); } .list-channels li { display: flex; @@ -353,36 +494,224 @@ input:focus { cursor: pointer; } -.list-channels li:hover { - background: rgba(255, 255, 255, 0.04); +.list-channels li:hover { background: rgba(47, 124, 255, 0.05); } +.list-channels li.active-channel { background: rgba(241, 124, 32, 0.08); border-left: 3px solid var(--orange); } + +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 0.75rem 0 1rem; +} + +@media (max-width: 380px) { + .two-col { grid-template-columns: 1fr; } } .chip { - font-size: 0.7rem; - padding: 0.15rem 0.5rem; + font-size: 0.66rem; + font-weight: 700; + padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--card-border); color: var(--text-muted); white-space: nowrap; } -footer { - max-width: 640px; - margin: 0 auto; - padding: 1rem 1.25rem 2rem; - text-align: center; +.chip-orange { background: rgba(241, 124, 32, 0.1); color: var(--orange-dark); border-color: transparent; } +.chip-blue { background: rgba(47, 124, 255, 0.1); color: var(--blue-dark); border-color: transparent; } +.chip-green { background: rgba(78, 186, 154, 0.15); color: var(--green-dark); border-color: transparent; } + +/* ─── TABS ──────────────────────────────────────────────── */ +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.tab-btn { + appearance: none; + border: 1px solid var(--card-border); + background: #fff; color: var(--text-muted); - font-size: 0.75rem; + border-radius: 999px; + padding: 0.4rem 0.9rem; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + font-family: 'Mulish', sans-serif; } -@media (max-width: 480px) { - .topbar { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .card { - padding: 1.1rem; - } +.tab-btn.active { + color: white; + background: linear-gradient(120deg, var(--blue), var(--blue-dark)); + border-color: transparent; +} + +/* ─── LOGIN ─────────────────────────────────────────────── */ +.login-wrap { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%); +} + +.login-logo { + font-family: 'Poppins', sans-serif; + font-size: 2rem; + font-weight: 800; + color: white; + text-align: center; +} + +.login-logo span { color: var(--orange); } + +.login-sub { + color: #a3aad0; + font-size: 0.85rem; + text-align: center; + margin-bottom: 26px; + margin-top: 4px; +} + +.login-card { + background: white; + border-radius: var(--radius); + padding: 22px; + width: 100%; + max-width: 360px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); +} + +/* ─── PROFILE ───────────────────────────────────────────── */ +.prof-hero { + background: linear-gradient(120deg, var(--dark), var(--dark-2)); + color: white; + border-radius: var(--radius); + padding: 22px; + text-align: center; + margin-bottom: 12px; + border: 1px solid #33365a; +} + +.prof-av { + width: 62px; + height: 62px; + border-radius: 50%; + background: var(--orange); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; + font-weight: 800; + margin: 0 auto 8px; + border: 3px solid var(--blue); +} + +.prof-name { + font-family: 'Poppins', sans-serif; + font-size: 1rem; + font-weight: 800; +} + +.prof-role { + font-size: 0.78rem; + color: #a3aad0; + margin-top: 2px; +} + +.si { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 12px; + margin-bottom: 8px; + font-size: 0.86rem; + font-weight: 700; + box-shadow: 0 2px 6px rgba(43, 45, 66, 0.05); +} + +/* ─── WORKSHOP PICK ─────────────────────────────────────── */ +.workshop-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: var(--card); + border: 1.5px solid var(--card-border); + border-radius: 12px; + padding: 11px 13px; + margin-bottom: 8px; + cursor: grab; +} + +.workshop-item.picked { + border-color: var(--blue); + background: rgba(47, 124, 255, 0.05); +} + +.workshop-rank { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--blue); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.78rem; + flex-shrink: 0; +} + +.workshop-rank.empty { + background: transparent; + border: 1.5px dashed var(--card-border); + color: transparent; +} + +/* ─── EMPTY STATE ───────────────────────────────────────── */ +.empty { + text-align: center; + padding: 30px 16px; + color: var(--text-muted); +} + +.empty-icon { font-size: 2.2rem; margin-bottom: 8px; } + +/* ─── TOAST ─────────────────────────────────────────────── */ +.toast { + position: absolute; + bottom: 78px; + left: 50%; + transform: translateX(-50%); + background: var(--dark); + color: white; + padding: 10px 20px; + border-radius: 20px; + font-size: 0.82rem; + font-weight: 700; + z-index: 300; + white-space: nowrap; + animation: toastUp 0.3s; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +@keyframes toastUp { + from { opacity: 0; transform: translateX(-50%) translateY(8px); } +} + +.dvd { height: 1px; background: var(--card-border); margin: 14px 0; } +.row { display: flex; align-items: center; gap: 8px; } +.spacer { flex: 1; } + +@media (max-width: 380px) { + .screen { padding: 14px 12px 20px; } }