import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'l10n/app_localizations.dart'; import 'app_state.dart'; import 'models/chat.dart'; import 'models/chat_message.dart'; import 'services/chat_service.dart'; import 'services/websocket_service.dart'; import 'services/notification_service.dart'; import 'widgets/chat_photo_dialog.dart'; import 'widgets/offline_banner.dart'; class ChatDetailsView extends StatefulWidget { final Chat chat; const ChatDetailsView({super.key, required this.chat}); @override State createState() => _ChatDetailsViewState(); } class _PreparedImage { const _PreparedImage({required this.base64DataUri, required this.bytes}); final String base64DataUri; final Uint8List bytes; } class _ChatDetailsViewState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); late List _messages; final WebSocketService _webSocketService = WebSocketService(); StreamSubscription>? _chatsStreamSubscription; String? _currentUserId; late final String _conversationKey; final ChatService _chatService = ChatService(); final Map _imageCache = {}; late Chat _activeChat; static const int _maxDisplayMessages = 30; @override void initState() { super.initState(); _activeChat = widget.chat; _conversationKey = _activeChat.id; NotificationService().activeConversationKey = _conversationKey; _messages = _lastMessages(_activeChat.messages); _currentUserId = AppState().loggedInEmail; _chatsStreamSubscription = _chatService.chatsStream.listen( _handleChatsUpdate, ); _chatService.initialize().then((_) async { _syncActiveChatFromService(replaceMessages: _messages.isEmpty); final history = await _chatService.loadMessagesForChat(_conversationKey); if (!mounted) return; if (history.isNotEmpty) { setState(() { _imageCache.clear(); _messages = _lastMessages(history); }); _scrollToBottom(immediate: true); } _syncActiveChatFromService(); await _chatService.markConversationRead(_conversationKey); }); // Scroll to bottom after initial build is complete _scrollToBottom(immediate: true); } @override void dispose() { if (NotificationService().activeConversationKey == _conversationKey) { NotificationService().activeConversationKey = null; } _chatsStreamSubscription?.cancel(); _messageController.dispose(); _scrollController.dispose(); super.dispose(); } void _scrollToBottom({bool immediate = false}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) { return; } final target = _scrollController.position.maxScrollExtent; if (immediate) { _scrollController.jumpTo(target); } else { _scrollController.animateTo( target, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } }); } void _handleChatsUpdate(List chats) { if (!mounted) { return; } final updated = _findChatById(chats); if (updated == null) { return; } final shouldReplace = _shouldReplaceMessages(updated); setState(() { _activeChat = updated; if (shouldReplace) { _imageCache.clear(); _messages = _lastMessages(updated.messages); } }); if (shouldReplace) { _scrollToBottom(); unawaited(_chatService.markConversationRead(_conversationKey)); } } bool _shouldReplaceMessages(Chat chat) { if (chat.messages.length != _messages.length) { return true; } if (_messages.isEmpty) { return false; } final currentLast = _messages.last; final updatedLast = chat.messages.last; return currentLast.id != updatedLast.id || currentLast.content != updatedLast.content || currentLast.contentType != updatedLast.contentType; } List _lastMessages(List messages) { final sorted = List.from(messages) ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); if (sorted.length > _maxDisplayMessages) { return sorted.sublist(sorted.length - _maxDisplayMessages); } return sorted; } Chat? _findChatById(List chats) { for (final chat in chats) { if (chat.id == _conversationKey) { return chat; } } return null; } void _syncActiveChatFromService({bool replaceMessages = false}) { final updated = _findChatById(_chatService.currentChats); if (updated == null || !mounted) { return; } final shouldReplace = replaceMessages || _shouldReplaceMessages(updated); setState(() { _activeChat = updated; if (shouldReplace) { _imageCache.clear(); _messages = _lastMessages(updated.messages); } }); if (shouldReplace) { _scrollToBottom(); unawaited(_chatService.markConversationRead(_conversationKey)); } } Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty) { return; } final sender = _currentUserId; final receiver = _activeChat.receiver; if (sender == null || sender.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).noSenderMessage), ), ); } return; } if (receiver == null || receiver.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).noRecipientMessage), ), ); } return; } final result = await _webSocketService.sendChatMessage( sender: sender, receiver: receiver, content: text, jobId: _activeChat.jobId, jobNumber: _activeChat.jobNumber, ); if (result == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).messageSendError), ), ); } return; } await _chatService.saveOutgoingMessage(result); _syncActiveChatFromService(); _messageController.clear(); _scrollToBottom(); } @override Widget build(BuildContext context) { final isJobChat = _activeChat.type == ChatType.jobSpecific; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_activeChat.title, style: const TextStyle(fontSize: 16)), if (isJobChat && _activeChat.jobNumber != null) Text( 'Job-Nr: ${_activeChat.jobNumber}', style: TextStyle( fontSize: 12, color: Colors.grey[600], fontWeight: FontWeight.normal, ), ), ], ), backgroundColor: Colors.deepPurple[100], actions: [ IconButton( icon: Icon(isJobChat ? Icons.work : Icons.support_agent), onPressed: () { // Show chat info _showChatInfo(); }, tooltip: AppLocalizations.of(context).chatInfo, ), ], ), body: Column( children: [ const OfflineBanner(), // Messages list Expanded( child: Container( decoration: BoxDecoration(color: Colors.grey[50]), child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(8, 8, 8, 96), itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; return _buildMessageBubble(message); }, ), ), ), // Message input _buildMessageInput(), ], ), ); } Widget _buildMessageBubble(ChatMessage message) { final isOwn = message.isOwn; final isImage = message.contentType == ChatContentType.image; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisAlignment: isOwn ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (!isOwn) const SizedBox(width: 8), Flexible( child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.7, ), margin: EdgeInsets.only( left: isOwn ? 40 : 0, right: isOwn ? 0 : 40, ), padding: EdgeInsets.symmetric( horizontal: isImage ? 6 : 12, vertical: isImage ? 6 : 8, ), decoration: BoxDecoration( color: isOwn ? Colors.deepPurple[100] : Colors.white, borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), topRight: const Radius.circular(12), bottomLeft: Radius.circular(isOwn ? 12 : 4), bottomRight: Radius.circular(isOwn ? 4 : 12), ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildMessageContent(message, isImage: isImage), const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ Text( _formatMessageTime(message.createdAt), style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), if (isOwn) ...[ const SizedBox(width: 4), Icon( message.pendingSync ? Icons.schedule : (message.read ? Icons.done_all : Icons.done), size: 14, color: message.pendingSync ? Colors.orange[700] : (message.read ? Colors.deepPurple[400] : Colors.grey[600]), ), ], ], ), ], ), ), ), if (isOwn) const SizedBox(width: 8), ], ), ); } Widget _buildMessageContent(ChatMessage message, {required bool isImage}) { if (!isImage) { return Text( message.content, style: TextStyle(fontSize: 15, color: Colors.grey[800]), ); } final imageBytes = _imageCache[message.id] ?? _decodeImageBytes(message); if (imageBytes == null) { return const Text( 'Bild konnte nicht geladen werden.', style: TextStyle(fontSize: 15), ); } return GestureDetector( onTap: () => _showImagePreview(imageBytes), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( color: Colors.black.withValues(alpha: 0.05), constraints: const BoxConstraints(maxWidth: 260, minWidth: 140), child: AspectRatio( aspectRatio: 4 / 3, child: Image.memory(imageBytes, fit: BoxFit.cover), ), ), ), ); } Uint8List? _decodeImageBytes(ChatMessage message) { final rawContent = message.content.trim(); if (rawContent.isEmpty) { return null; } final base64Payload = rawContent.startsWith('data:') ? rawContent.substring(rawContent.indexOf(',') + 1) : rawContent; final normalized = base64Payload.replaceAll(RegExp(r'\s'), ''); try { final bytes = base64Decode(normalized); _imageCache[message.id] = bytes; return bytes; } catch (_) { return null; } } Future _showImagePreview(Uint8List imageBytes) async { if (!mounted) return; await showDialog( context: context, builder: (context) { return Dialog( insetPadding: const EdgeInsets.all(16), backgroundColor: Colors.black, child: InteractiveViewer( child: Image.memory(imageBytes, fit: BoxFit.contain), ), ); }, ); } Widget _buildMessageInput() { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey[300]!)), ), child: SafeArea( child: Row( children: [ GestureDetector( onTap: _handleAttachmentTap, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(20), ), child: const Icon( Icons.attach_file, color: Colors.black87, size: 20, ), ), ), const SizedBox(width: 8), Expanded( child: Container( decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(20), ), child: TextField( controller: _messageController, decoration: InputDecoration( hintText: AppLocalizations.of(context).typeMessage, contentPadding: EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), border: InputBorder.none, ), maxLines: null, keyboardType: TextInputType.multiline, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), ), ), ), const SizedBox(width: 8), GestureDetector( onTap: () { _sendMessage(); }, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.deepPurple, borderRadius: BorderRadius.circular(20), ), child: const Icon(Icons.send, color: Colors.white, size: 20), ), ), ], ), ), ); } Future _handleAttachmentTap() async { if (!mounted) return; final Uint8List? photoBytes = await showDialog( context: context, builder: (context) => const ChatPhotoDialog(), ); if (photoBytes == null || photoBytes.isEmpty) { return; } await _sendImageMessage(photoBytes); } Future _sendImageMessage(Uint8List imageBytes) async { final sender = _currentUserId; final receiver = _activeChat.receiver; if (sender == null || sender.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).noSenderMessage), ), ); } return; } if (receiver == null || receiver.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).noRecipientMessage), ), ); } return; } final prepared = await _prepareImagePayload(imageBytes); if (prepared == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).photoProcessError), ), ); } return; } final result = await _webSocketService.sendChatMessage( sender: sender, receiver: receiver, content: prepared.base64DataUri, contentType: ChatContentType.image, jobId: _activeChat.jobId, jobNumber: _activeChat.jobNumber, ); if (result == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).imageSendError)), ); } return; } await _chatService.saveOutgoingMessage(result); _syncActiveChatFromService(); if (prepared.bytes.isNotEmpty) { _imageCache[result.id] = prepared.bytes; } } Future<_PreparedImage?> _prepareImagePayload(Uint8List originalBytes) async { try { final decoded = img.decodeImage(originalBytes); if (decoded == null) { return null; } final baked = img.bakeOrientation(decoded); const maxDimension = 1280; img.Image processed = baked; if (baked.width > maxDimension || baked.height > maxDimension) { final scale = baked.width > baked.height ? maxDimension / baked.width : maxDimension / baked.height; final targetWidth = (baked.width * scale).round(); final targetHeight = (baked.height * scale).round(); processed = img.copyResize( baked, width: targetWidth, height: targetHeight, interpolation: img.Interpolation.average, ); } final encodedBytes = Uint8List.fromList( img.encodeJpg(processed, quality: 85), ); final base64Payload = base64Encode(encodedBytes); final dataUri = 'data:image/jpeg;base64,$base64Payload'; return _PreparedImage(base64DataUri: dataUri, bytes: encodedBytes); } catch (_) { return null; } } String _formatMessageTime(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day); if (messageDate == today) { // Today - show only time return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; } else if (messageDate == today.subtract(const Duration(days: 1))) { // Yesterday return 'Gestern ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; } else { // Older - show date and time return '${dateTime.day.toString().padLeft(2, '0')}.${dateTime.month.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; } } void _showChatInfo() { final isJobChat = _activeChat.type == ChatType.jobSpecific; showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(_activeChat.title), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${AppLocalizations.of(context).status}: ${isJobChat ? AppLocalizations.of(context).chatTypeJob : AppLocalizations.of(context).chatTypeGeneral}'), const SizedBox(height: 8), if (isJobChat && _activeChat.jobNumber != null) ...[ Text('${AppLocalizations.of(context).jobNumber}: ${_activeChat.jobNumber}'), const SizedBox(height: 8), ], Text('${AppLocalizations.of(context).messages}: ${_messages.length}'), const SizedBox(height: 8), Text( 'Erstellt: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}', ), ], ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text(AppLocalizations.of(context).close), ), ], ); }, ); } }