import 'dart:math' as math; import 'package:flutter/material.dart'; import '../api.dart'; import '../chat_socket.dart'; import '../main.dart'; /// WhatsApp brand colors abstract final class WhatsAppColors { static const primary = Color(0xFF008069); // WhatsApp Header Green static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG static const iconMuted = Color(0xFF54656F); } /// Chat channel overview screen with WhatsApp look and feel. class ChatScreen extends StatefulWidget { const ChatScreen({super.key, required this.kcId}); final String kcId; @override State 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', 'GRUPPE': 'Gruppenchat', }; static const _typeIcons = { 'GEMEINDE_GRUPPE': Icons.people_alt_rounded, 'DIREKT': Icons.person_rounded, 'LT_UEBERGREIFEND': Icons.shield_rounded, 'BROADCAST': Icons.campaign_rounded, 'GRUPPE': Icons.groups_rounded, }; static const _typeColors = { 'GEMEINDE_GRUPPE': Color(0xFF008069), 'DIREKT': Color(0xFF2F7CFF), 'LT_UEBERGREIFEND': Color(0xFF6C5CE7), 'BROADCAST': Color(0xFFF17C20), 'GRUPPE': Color(0xFF0984E3), }; @override Widget build(BuildContext context) { return Theme( data: Theme.of(context).copyWith( appBarTheme: const AppBarTheme( backgroundColor: WhatsAppColors.primary, foregroundColor: Colors.white, elevation: 1, iconTheme: IconThemeData(color: Colors.white), titleTextStyle: TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.w700, ), ), ), child: Scaffold( backgroundColor: Colors.white, appBar: AppBar( title: const Text('Chats'), actions: [ IconButton( icon: const Icon(Icons.search), tooltip: 'Suchen', onPressed: () {}, ), IconButton( icon: const Icon(Icons.more_vert), tooltip: 'Optionen', onPressed: () {}, ), ], ), body: FutureBuilder>( future: _future, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const Center( child: CircularProgressIndicator(color: WhatsAppColors.primary), ); } if (snap.hasError) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), const SizedBox(height: 12), Text('${snap.error}', textAlign: TextAlign.center), ], ), ), ); } final channels = snap.data!; if (channels.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400), const SizedBox(height: 12), Text( 'Keine Kanäle sichtbar', style: TextStyle(fontSize: 16, color: Colors.grey.shade600), ), ], ), ); } return ListView.separated( itemCount: channels.length, separatorBuilder: (_, __) => const Divider( indent: 80, endIndent: 16, height: 1, thickness: 0.8, color: Color(0xFFF0F2F5), ), itemBuilder: (context, i) { final c = channels[i]; final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type); final icon = _typeIcons[c.type] ?? Icons.chat_rounded; final color = _typeColors[c.type] ?? WhatsAppColors.primary; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), leading: CircleAvatar( radius: 25, backgroundColor: color.withValues(alpha: 0.15), child: Icon(icon, color: color, size: 28), ), title: Row( children: [ Expanded( child: Text( label, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: WhatsAppColors.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), subtitle: Text( _typeLabels[c.type] ?? c.type, style: const TextStyle( fontSize: 13.5, color: WhatsAppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => _ChannelMessages( channelId: c.id, title: label, subtitle: _typeLabels[c.type] ?? c.type, icon: icon, iconColor: color, ), ), ), ); }, ); }, ), ), ); } } /// WhatsApp-style chat message screen. class _ChannelMessages extends StatefulWidget { const _ChannelMessages({ required this.channelId, required this.title, required this.subtitle, required this.icon, required this.iconColor, }); final String channelId; final String title; final String subtitle; final IconData icon; final Color iconColor; @override State<_ChannelMessages> createState() => _ChannelMessagesState(); } class _ChannelMessagesState extends State<_ChannelMessages> { final List _messages = []; final _composer = TextEditingController(); final _scroll = ScrollController(); ChatSocket? _socket; bool _loading = true; String? _loadError; String _wsStatus = 'verbinde…'; bool _hasText = false; @override void initState() { super.initState(); _composer.addListener(() { final hasText = _composer.text.trim().isNotEmpty; if (hasText != _hasText) { setState(() => _hasText = hasText); } }); } @override void didChangeDependencies() { super.didChangeDependencies(); if (_socket != null) return; final api = AppScope.of(context).api; _load(api); _socket = ChatSocket(api.chatWsUri()) ..connect(widget.channelId) ..messages.listen(_onIncoming) ..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null); } Future _load(Api api) async { try { final history = await api.messages(widget.channelId); if (!mounted) return; setState(() { _messages ..clear() ..addAll(history); _loading = false; }); _jump(); } catch (e) { if (mounted) setState(() { _loadError = '$e'; _loading = false; }); } } void _onIncoming(ChatMessage m) { if (!mounted) return; setState(() => _messages.add(m)); _jump(); } void _jump() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scroll.hasClients) { _scroll.animateTo( _scroll.position.maxScrollExtent, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } }); } void _send() { final text = _composer.text.trim(); if (text.isEmpty) return; _socket?.sendMessage(widget.channelId, text); _composer.clear(); } @override void dispose() { _socket?.dispose(); _composer.dispose(); _scroll.dispose(); super.dispose(); } String _formatTime(String rawDate) { final dt = DateTime.tryParse(rawDate)?.toLocal(); if (dt == null) return rawDate; final hour = dt.hour.toString().padLeft(2, '0'); final minute = dt.minute.toString().padLeft(2, '0'); return '$hour:$minute'; } String _formatDateHeader(DateTime date) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final messageDate = DateTime(date.year, date.month, date.day); if (messageDate == today) { return 'HEUTE'; } else if (messageDate == today.subtract(const Duration(days: 1))) { return 'GESTERN'; } else { final d = date.day.toString().padLeft(2, '0'); final m = date.month.toString().padLeft(2, '0'); return '$d.$m.${date.year}'; } } bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } Color _getSenderColor(String id) { final colors = [ const Color(0xFF1E88E5), const Color(0xFFE53935), const Color(0xFF8E24AA), const Color(0xFF3949AB), const Color(0xFF00897B), const Color(0xFFD81B60), const Color(0xFFFB8C00), const Color(0xFF43A047), ]; var hash = 0; for (var i = 0; i < id.length; i++) { hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF; } return colors[hash % colors.length]; } @override Widget build(BuildContext context) { final identity = AppScope.of(context).identity; return Theme( data: Theme.of(context).copyWith( appBarTheme: const AppBarTheme( backgroundColor: WhatsAppColors.primary, foregroundColor: Colors.white, elevation: 1, iconTheme: IconThemeData(color: Colors.white), titleTextStyle: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, ), ), ), child: Scaffold( backgroundColor: WhatsAppColors.chatBackground, appBar: AppBar( titleSpacing: 0, title: Row( children: [ CircleAvatar( radius: 19, backgroundColor: Colors.white.withValues(alpha: 0.2), child: Icon(widget.icon, color: Colors.white, size: 22), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.title, style: const TextStyle( fontSize: 16.5, fontWeight: FontWeight.w600, color: Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 1), Text( _wsStatus == 'verbunden' || _wsStatus == 'connected' ? 'online' : _wsStatus, style: TextStyle( fontSize: 11.5, color: Colors.white.withValues(alpha: 0.85), fontWeight: FontWeight.normal, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), actions: [ IconButton( icon: const Icon(Icons.videocam_rounded), tooltip: 'Videoanruf', onPressed: () {}, ), IconButton( icon: const Icon(Icons.call_rounded), tooltip: 'Anruf', onPressed: () {}, ), IconButton( icon: const Icon(Icons.more_vert), tooltip: 'Optionen', onPressed: () {}, ), ], ), body: Stack( children: [ // WhatsApp Doodle Pattern Background Positioned.fill( child: CustomPaint( painter: _WhatsAppDoodlePainter(), ), ), // Chat Content Column( children: [ Expanded(child: _buildMessagesList(context, identity)), _buildComposer(context), ], ), ], ), ), ); } Widget _buildMessagesList(BuildContext context, Identity? identity) { if (_loading) { return const Center( child: CircularProgressIndicator(color: WhatsAppColors.primary), ); } if (_loadError != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), const SizedBox(height: 12), Text(_loadError!, textAlign: TextAlign.center), ], ), ), ); } if (_messages.isEmpty) { return Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: WhatsAppColors.dateBadgeBg, borderRadius: BorderRadius.circular(8), boxShadow: const [ BoxShadow( color: Color(0x14000000), blurRadius: 3, offset: Offset(0, 1), ), ], ), child: const Text( 'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, color: WhatsAppColors.dateBadgeText, fontWeight: FontWeight.w500, ), ), ), ); } final items = []; DateTime? lastDate; for (var i = 0; i < _messages.length; i++) { final m = _messages[i]; final msgDate = DateTime.tryParse(m.createdAt)?.toLocal(); // Insert date divider if day changed if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) { items.add(_buildDateHeader(_formatDateHeader(msgDate))); lastDate = msgDate; } final isMe = (identity?.kind == SessionKind.user && m.senderUserId != null && m.senderUserId == identity?.userId) || (identity?.kind == SessionKind.guest && m.senderGuestId != null && m.senderGuestId == identity?.guestId); items.add(_buildMessageBubble(m, isMe)); } return ListView( controller: _scroll, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), children: items, ); } Widget _buildDateHeader(String text) { return Center( child: Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( color: WhatsAppColors.dateBadgeBg, borderRadius: BorderRadius.circular(7.5), boxShadow: const [ BoxShadow( color: Color(0x10000000), blurRadius: 2, offset: Offset(0, 1), ), ], ), child: Text( text, style: const TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, letterSpacing: 0.3, color: WhatsAppColors.dateBadgeText, ), ), ), ); } Widget _buildMessageBubble(ChatMessage m, bool isMe) { final timeStr = _formatTime(m.createdAt); final senderId = m.senderUserId ?? m.senderGuestId; final showSender = !isMe && senderId != null; final senderColor = showSender ? _getSenderColor(senderId) : Colors.black; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.78, minWidth: 80, ), child: Container( margin: const EdgeInsets.only(bottom: 4, top: 2), decoration: BoxDecoration( color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble, borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), topRight: const Radius.circular(12), bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2), bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12), ), boxShadow: const [ BoxShadow( color: Color(0x18000000), blurRadius: 2, offset: Offset(0, 1), ), ], ), child: Padding( padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (showSender) ...[ Text( m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung', style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, color: senderColor, ), ), const SizedBox(height: 2), ], Wrap( alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.bottom, spacing: 8, runSpacing: 2, children: [ Text( m.body, style: const TextStyle( fontSize: 15, color: WhatsAppColors.textPrimary, height: 1.25, ), ), Padding( padding: const EdgeInsets.only(top: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( timeStr, style: const TextStyle( fontSize: 10.5, color: WhatsAppColors.textSecondary, ), ), if (isMe) ...[ const SizedBox(width: 3), const Icon( Icons.done_all_rounded, size: 15, color: WhatsAppColors.checkmarkBlue, ), ], ], ), ), ], ), ], ), ), ), ), ); } Widget _buildComposer(BuildContext context) { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: const [ BoxShadow( color: Color(0x14000000), blurRadius: 3, offset: Offset(0, 1), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( icon: const Icon(Icons.emoji_emotions_outlined), color: WhatsAppColors.iconMuted, splashRadius: 20, onPressed: () {}, ), Expanded( child: TextField( controller: _composer, minLines: 1, maxLines: 5, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( hintText: 'Nachricht', hintStyle: TextStyle( color: WhatsAppColors.textSecondary, fontSize: 15.5, ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4), ), onSubmitted: (_) => _send(), ), ), IconButton( icon: const Icon(Icons.attach_file_rounded), color: WhatsAppColors.iconMuted, splashRadius: 20, onPressed: () {}, ), if (!_hasText) IconButton( icon: const Icon(Icons.camera_alt_rounded), color: WhatsAppColors.iconMuted, splashRadius: 20, onPressed: () {}, ), ], ), ), ), const SizedBox(width: 6), Container( decoration: const BoxDecoration( color: WhatsAppColors.accent, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Color(0x28000000), blurRadius: 4, offset: Offset(0, 2), ), ], ), child: IconButton( icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded), color: Colors.white, splashRadius: 24, onPressed: _send, ), ), ], ), ), ); } } /// Custom painter for the iconic subtle WhatsApp background doodle canvas. class _WhatsAppDoodlePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = const Color(0xFF4A6B82).withValues(alpha: 0.05) ..style = PaintingStyle.stroke ..strokeWidth = 1.3; final fillPaint = Paint() ..color = const Color(0xFF4A6B82).withValues(alpha: 0.035) ..style = PaintingStyle.fill; const spacing = 75.0; final rows = (size.height / spacing).ceil() + 1; final cols = (size.width / spacing).ceil() + 1; for (var r = 0; r < rows; r++) { for (var c = 0; c < cols; c++) { final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0); final y = r * spacing; final type = (r * 7 + c * 11) % 6; canvas.save(); canvas.translate(x, y); switch (type) { case 0: // Chat bubble doodle final rrect = RRect.fromRectAndRadius( const Rect.fromLTWH(-10, -8, 20, 16), const Radius.circular(5), ); canvas.drawRRect(rrect, fillPaint); canvas.drawRRect(rrect, paint); break; case 1: // Small Star final path = Path(); for (var i = 0; i < 5; i++) { final angle = i * 4 * math.pi / 5 - math.pi / 2; final px = 8 * math.cos(angle); final py = 8 * math.sin(angle); if (i == 0) { path.moveTo(px, py); } else { path.lineTo(px, py); } } path.close(); canvas.drawPath(path, fillPaint); canvas.drawPath(path, paint); break; case 2: // Heart doodle final path = Path(); path.moveTo(0, 4); path.cubicTo(-6, -2, -10, -8, 0, -10); path.cubicTo(10, -8, 6, -2, 0, 4); canvas.drawPath(path, fillPaint); canvas.drawPath(path, paint); break; case 3: // Musical note canvas.drawCircle(const Offset(-4, 4), 3, fillPaint); canvas.drawCircle(const Offset(-4, 4), 3, paint); canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint); canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint); break; case 4: // Coffee / cup final rrect = RRect.fromRectAndRadius( const Rect.fromLTWH(-7, -5, 14, 12), const Radius.circular(3), ); canvas.drawRRect(rrect, fillPaint); canvas.drawRRect(rrect, paint); canvas.drawArc( const Rect.fromLTWH(4, -3, 6, 6), -math.pi / 2, math.pi, false, paint, ); break; case 5: // Clock / circle canvas.drawCircle(Offset.zero, 7, fillPaint); canvas.drawCircle(Offset.zero, 7, paint); canvas.drawLine(Offset.zero, const Offset(0, -4), paint); canvas.drawLine(Offset.zero, const Offset(3, 0), paint); break; } canvas.restore(); } } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }