Files
votianlt/app/lib/chat_details_view.dart

693 lines
21 KiB
Dart

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<ChatDetailsView> createState() => _ChatDetailsViewState();
}
class _PreparedImage {
const _PreparedImage({required this.base64DataUri, required this.bytes});
final String base64DataUri;
final Uint8List bytes;
}
class _ChatDetailsViewState extends State<ChatDetailsView> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late List<ChatMessage> _messages;
final WebSocketService _webSocketService = WebSocketService();
StreamSubscription<List<Chat>>? _chatsStreamSubscription;
String? _currentUserId;
late final String _conversationKey;
final ChatService _chatService = ChatService();
final Map<String, Uint8List> _imageCache = <String, Uint8List>{};
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<Chat> 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<ChatMessage> _lastMessages(List<ChatMessage> messages) {
final sorted = List<ChatMessage>.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<Chat> 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<void> _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<void> _showImagePreview(Uint8List imageBytes) async {
if (!mounted) return;
await showDialog<void>(
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<void> _handleAttachmentTap() async {
if (!mounted) return;
final Uint8List? photoBytes = await showDialog<Uint8List>(
context: context,
builder: (context) => const ChatPhotoDialog(),
);
if (photoBytes == null || photoBytes.isEmpty) {
return;
}
await _sendImageMessage(photoBytes);
}
Future<void> _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),
),
],
);
},
);
}
}