- Chat: Nachrichten-Status (read/unread), WebSocket-Verbesserungen - App: Login-Optimierung, Job-Übersicht verbessert, neue Übersetzungen - Backend: Dialog-Styling, Invoice-Generator, Job-Verwaltung erweitert - Mehrsprachigkeit: Neue Übersetzungen für DE, EN, ES, ET, FR, LT, LV, PL, RU, TR
697 lines
21 KiB
Dart
697 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 'l10n/localization_helpers.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;
|
|
}
|
|
|
|
_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(
|
|
localizedChatTitle(context, _activeChat),
|
|
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;
|
|
}
|
|
|
|
_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 '${AppLocalizations.of(context).yesterday} ${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(localizedChatTitle(context, _activeChat)),
|
|
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(
|
|
'${AppLocalizations.of(context).created}: ${_formatMessageTime(_messages.isNotEmpty ? _messages.first.createdAt : DateTime.now())}',
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(AppLocalizations.of(context).close),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|