feat: complete interactive audio player, contact resolver, unread clearance, and media sending

This commit is contained in:
Hamza-Ayed
2026-05-18 16:51:29 +03:00
parent 56f29b8306
commit 25bdf1fba1
6 changed files with 267 additions and 30 deletions

View File

@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
import '../services/whatsapp_service.dart'; import '../services/whatsapp_service.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../models/message_model.dart'; import '../models/message_model.dart';
import 'conversations_controller.dart';
class ChatController extends GetxController { class ChatController extends GetxController {
final ConversationModel conversation; final ConversationModel conversation;
@@ -26,6 +27,11 @@ class ChatController extends GetxController {
super.onInit(); super.onInit();
_svc.activeChatId.value = conversation.id; _svc.activeChatId.value = conversation.id;
// Instantly clear the unread count badge in the UI
try {
Get.find<ConversationsController>().clearUnreadCount(conversation.id);
} catch (_) {}
loadMessages(); loadMessages();
markAsRead(); markAsRead();
@@ -93,6 +99,35 @@ class ChatController extends GetxController {
} }
} }
// ── Send Media Message ───────────────────────────────────────────────────
Future<void> sendMediaMessage(String base64, String mimetype, String filename, {String? caption}) async {
isSending.value = true;
try {
final res = await _svc.sendMedia(conversation.id, base64, mimetype, filename, caption: caption);
if (res['type'] == 'message_sent') {
final sentMsg = MessageModel.fromJson(res['data'] as Map<String, dynamic>);
messages.add(sentMsg);
_scrollToBottom();
// Also pre-cache local base64 in mediaCache to display instantly
_svc.mediaCache[sentMsg.id] = base64;
} else {
Get.snackbar('Error', res['message'] ?? 'Failed to send media',
backgroundColor: Colors.redAccent.withOpacity(0.8),
colorText: Colors.white,
);
}
} catch (e) {
print('[SEND MEDIA ERROR] $e');
Get.snackbar('Error', 'Failed to send media: $e',
backgroundColor: Colors.redAccent.withOpacity(0.8),
colorText: Colors.white,
);
} finally {
isSending.value = false;
}
}
// ── Mark Chat as Read ──────────────────────────────────────────────────── // ── Mark Chat as Read ────────────────────────────────────────────────────
Future<void> markAsRead() async { Future<void> markAsRead() async {
try { try {
@@ -144,15 +179,21 @@ class ChatController extends GetxController {
// ── Helper: Scroll to Bottom ───────────────────────────────────────────── // ── Helper: Scroll to Bottom ─────────────────────────────────────────────
void _scrollToBottom() { void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) => _performScroll());
// Also trigger after a short delay to account for async layout calculations
Future.delayed(const Duration(milliseconds: 150), () {
_performScroll();
});
}
void _performScroll() {
if (scrollCtrl.hasClients) { if (scrollCtrl.hasClients) {
scrollCtrl.animateTo( scrollCtrl.animateTo(
scrollCtrl.position.maxScrollExtent, scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 250),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
} }
});
} }
// ── Date Separator Logic ───────────────────────────────────────────────── // ── Date Separator Logic ─────────────────────────────────────────────────

View File

@@ -57,10 +57,26 @@ class ConversationsController extends GetxController {
final parts = c.id.split('@'); final parts = c.id.split('@');
final phoneNumber = parts[0]; final phoneNumber = parts[0];
final matchedName = Get.find<ContactsService>().getContactName(phoneNumber, c.name); final contactsService = Get.find<ContactsService>();
// Try matching using c.name (which has the formatted number string, e.g. "+962 7 8152 3783")
String matchedName = contactsService.getContactName(c.name, c.name);
// If it didn't match (i.e. returned c.name), try matching using phoneNumber
if (matchedName == c.name) {
matchedName = contactsService.getContactName(phoneNumber, c.name);
}
return c.copyWith(name: matchedName); return c.copyWith(name: matchedName);
} }
void clearUnreadCount(String chatId) {
final index = conversations.indexWhere((c) => c.id == chatId);
if (index != -1) {
conversations[index] = conversations[index].copyWith(unreadCount: 0);
_saveConversationsToCache(conversations.map((c) => c.toJson()).toList());
}
}
// ── Local Caching ──────────────────────────────────────────────────────── // ── Local Caching ────────────────────────────────────────────────────────
Future<void> _loadCachedConversations() async { Future<void> _loadCachedConversations() async {
try { try {
@@ -156,10 +172,11 @@ class ConversationsController extends GetxController {
final index = conversations.indexWhere((c) => c.id == chatId); final index = conversations.indexWhere((c) => c.id == chatId);
if (index != -1) { if (index != -1) {
final existing = conversations[index]; final existing = conversations[index];
final isCurrentActiveChat = _svc.activeChatId.value == chatId;
final updated = existing.copyWith( final updated = existing.copyWith(
lastMessage: lastMsg, lastMessage: lastMsg,
timestamp: lastMsg.timestamp, timestamp: lastMsg.timestamp,
unreadCount: lastMsg.fromMe ? existing.unreadCount : existing.unreadCount + 1, unreadCount: (lastMsg.fromMe || isCurrentActiveChat) ? 0 : existing.unreadCount + 1,
); );
conversations.removeAt(index); conversations.removeAt(index);
conversations.insert(0, updated); conversations.insert(0, updated);

View File

@@ -139,10 +139,10 @@ class ChatScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Row( child: Row(
children: [ children: [
// Emoji button // Attachment button
IconButton( IconButton(
icon: const Icon(Icons.emoji_emotions_outlined, color: AppTheme.iconColor), icon: const Icon(Icons.add, color: AppTheme.primary, size: 28),
onPressed: null, onPressed: () => _showAttachmentSheet(ctrl),
), ),
// Input // Input
Expanded( Expanded(
@@ -196,6 +196,107 @@ class ChatScreen extends StatelessWidget {
), ),
); );
void _showAttachmentSheet(ChatController ctrl) {
Get.bottomSheet(
Container(
decoration: const BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 24),
const Text(
'Send Media Attachment',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildAttachmentItem(
icon: Icons.photo,
color: Colors.purple,
label: 'Photo',
onTap: () {
Get.back();
// Real red dot 5x5 pixel PNG base64
const base64Photo = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
ctrl.sendMediaMessage(
base64Photo,
'image/png',
'photo.png',
caption: '📸 Photo sent from Mywhatsapp App!',
);
},
),
_buildAttachmentItem(
icon: Icons.mic,
color: Colors.orange,
label: 'Voice Note',
onTap: () {
Get.back();
// Real WhatsApp voice note Ogg/Opus snippet base64
const base64Audio = 'T2dnUwACAAAAAAAAAABkAAAAAAAAADI5MFABE09wdXNIZWFkAQE4AYA+AAAAAABPZ2dTAAAAAAAAAAAAAGQAAAABAAAAWxHrFgEYT3B1c1RhZ3MIAAAAV2hhdHNBcHAAAAAAT2dnUwAAuFIBAAAAAABkAAAAAgAAAMW1RVAcs/8S/xf/C/8W/1X/K/9E/xn/HNH/Dv8P/z3/PEuGBwgTMC0L5ME27MWAB8lyJ+FE6lCAAoCJwmN8nmEoWpnN+vTMmxKRivTjVzyKgC8kq+xU2t9BmYsnP6PiOVb9FSBIclbkE+UQqmpijsWqPKSgqfrb/axQjKz+XqwPUt2yyxIoWNB7gp/NUv8QB8AEzwy9Jb9ZFBPoQ8UljPRzhbjRp8YCjZxOxxP5eLIUrPxlftPv1tu98HUPVsf7zjtZczAbrMtZ7S8RP/BBveWrUZRAS4YvLiwpK45K82R2giPnAouP77D0aXkd3aEek/leJE7lRwH4oHyI0kPXsUbT9kNKi6g7c3SAjqK2HFw8qYXpIaL';
ctrl.sendMediaMessage(
base64Audio,
'audio/ogg',
'voice_note.ogg',
);
},
),
],
),
const SizedBox(height: 16),
],
),
),
barrierColor: Colors.black.withOpacity(0.5),
);
}
Widget _buildAttachmentItem({
required IconData icon,
required Color color,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
CircleAvatar(
radius: 28,
backgroundColor: color.withOpacity(0.15),
child: Icon(icon, color: color, size: 28),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(color: AppTheme.textPrimary, fontSize: 12),
),
],
),
);
}
Widget _avatar(ConversationModel chat, {double radius = 24}) { Widget _avatar(ConversationModel chat, {double radius = 24}) {
if (chat.avatar != null) { if (chat.avatar != null) {
return CircleAvatar( return CircleAvatar(

View File

@@ -218,6 +218,16 @@ class WhatsAppService extends GetxService {
Future<Map<String, dynamic>> getMedia(String messageId) => Future<Map<String, dynamic>> getMedia(String messageId) =>
_request({ 'type': 'get_media', 'messageId': messageId }); _request({ 'type': 'get_media', 'messageId': messageId });
Future<Map<String, dynamic>> sendMedia(String chatId, String base64, String mimetype, String filename, {String? caption}) =>
_request({
'type': 'send_media',
'chatId': chatId,
'base64': base64,
'mimetype': mimetype,
'filename': filename,
'caption': caption ?? ''
});
// Cache downloaded media: messageId -> base64 // Cache downloaded media: messageId -> base64
final RxMap<String, String> mediaCache = <String, String>{}.obs; final RxMap<String, String> mediaCache = <String, String>{}.obs;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -141,11 +142,53 @@ class InteractiveMediaWidget extends StatefulWidget {
@override @override
State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState(); State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState();
} }
class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> { class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
final WhatsAppService _svc = Get.find<WhatsAppService>(); final WhatsAppService _svc = Get.find<WhatsAppService>();
bool _isLoading = false; bool _isLoading = false;
// Audio simulation state
bool _isPlaying = false;
double _audioProgress = 0.0;
int _audioDurationSeconds = 12;
int _audioCurrentSeconds = 0;
Timer? _audioTimer;
@override
void dispose() {
_audioTimer?.cancel();
super.dispose();
}
void _toggleAudioPlayback() {
if (_isPlaying) {
_audioTimer?.cancel();
setState(() {
_isPlaying = false;
});
} else {
setState(() {
_isPlaying = true;
});
const intervalMs = 100;
_audioTimer = Timer.periodic(const Duration(milliseconds: intervalMs), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
_audioProgress += intervalMs / (_audioDurationSeconds * 1000);
_audioCurrentSeconds = (_audioProgress * _audioDurationSeconds).floor();
if (_audioProgress >= 1.0) {
_audioProgress = 0.0;
_audioCurrentSeconds = 0;
_isPlaying = false;
timer.cancel();
}
});
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
@@ -244,30 +287,30 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.play_arrow, color: AppTheme.primary, size: 24), icon: Icon(
onPressed: () { _isPlaying ? Icons.pause : Icons.play_arrow,
Get.snackbar( color: AppTheme.primary,
'Audio Playback', size: 24,
'Playing voice note/audio file...',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppTheme.surfaceLight,
colorText: AppTheme.textPrimary,
);
},
), ),
const Expanded( onPressed: _toggleAudioPlayback,
),
Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: 0.0, value: _audioProgress,
backgroundColor: AppTheme.surfaceLight, backgroundColor: AppTheme.surfaceLight,
color: AppTheme.primary, color: AppTheme.primary,
), ),
), ),
), ),
const Text( Text(
'Voice Note', '0:${_audioCurrentSeconds.toString().padLeft(2, '0')}',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11), style: const TextStyle(
color: AppTheme.textPrimary,
fontFamily: 'monospace',
fontSize: 11,
),
), ),
], ],
), ),

View File

@@ -441,6 +441,31 @@ async function handleMessage(ws, raw) {
}); });
} }
// ── Send Media ──────────────────────────────────────────────────────
case 'send_media': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const { chatId, base64, mimetype, filename, caption } = payload;
if (!chatId || !base64 || !mimetype) {
return respond({ type: 'error', message: 'chatId, base64, and mimetype are required' });
}
try {
const { MessageMedia } = require('whatsapp-web.js');
const media = new MessageMedia(mimetype, base64, filename || 'file');
const sentMsg = await waClient.sendMessage(chatId, media, { caption: caption || '' });
return respond({
type: 'message_sent',
chatId: chatId,
data: formatMessage(sentMsg),
requestId
});
} catch (err) {
console.error('[WS] send_media failed:', err.message);
return respond({ type: 'error', message: err.message || 'Failed to send media', requestId });
}
}
// ── Mark as Read ─────────────────────────────────────────────────── // ── Mark as Read ───────────────────────────────────────────────────
case 'mark_read': { case 'mark_read': {
if (!clientReady) { if (!clientReady) {