feat: complete interactive audio player, contact resolver, unread clearance, and media sending
This commit is contained in:
@@ -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,17 +179,23 @@ class ChatController extends GetxController {
|
|||||||
|
|
||||||
// ── Helper: Scroll to Bottom ─────────────────────────────────────────────
|
// ── Helper: Scroll to Bottom ─────────────────────────────────────────────
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) => _performScroll());
|
||||||
if (scrollCtrl.hasClients) {
|
// Also trigger after a short delay to account for async layout calculations
|
||||||
scrollCtrl.animateTo(
|
Future.delayed(const Duration(milliseconds: 150), () {
|
||||||
scrollCtrl.position.maxScrollExtent,
|
_performScroll();
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _performScroll() {
|
||||||
|
if (scrollCtrl.hasClients) {
|
||||||
|
scrollCtrl.animateTo(
|
||||||
|
scrollCtrl.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Date Separator Logic ─────────────────────────────────────────────────
|
// ── Date Separator Logic ─────────────────────────────────────────────────
|
||||||
List<dynamic> get groupedMessages {
|
List<dynamic> get groupedMessages {
|
||||||
final list = <dynamic>[];
|
final list = <dynamic>[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
onPressed: _toggleAudioPlayback,
|
||||||
backgroundColor: AppTheme.surfaceLight,
|
|
||||||
colorText: AppTheme.textPrimary,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user