Sync update: 2026-05-18 16:14:25

This commit is contained in:
Hamza-Ayed
2026-05-18 16:14:25 +03:00
parent 7b6e4b3111
commit 905819a1d5
9 changed files with 388 additions and 60 deletions

View File

@@ -66,5 +66,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSContactsUsageDescription</key>
<string>This app requires contacts access to match phone numbers with your local address book names.</string>
</dict>
</plist>

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/whatsapp_service.dart';
import '../services/contacts_service.dart';
import '../models/conversation_model.dart';
class ConversationsController extends GetxController {
@@ -49,6 +50,17 @@ class ConversationsController extends GetxController {
super.onClose();
}
// Helper to resolve contact names from the local address book
ConversationModel _resolveContactNames(ConversationModel c) {
if (c.isGroup) return c; // Skip group chats
final parts = c.id.split('@');
final phoneNumber = parts[0];
final matchedName = Get.find<ContactsService>().getContactName(phoneNumber, c.name);
return c.copyWith(name: matchedName);
}
// ── Local Caching ────────────────────────────────────────────────────────
Future<void> _loadCachedConversations() async {
try {
@@ -56,7 +68,7 @@ class ConversationsController extends GetxController {
final cached = prefs.getString('cached_conversations');
if (cached != null) {
final List<dynamic> decoded = jsonDecode(cached);
conversations.assignAll(decoded.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
conversations.assignAll(decoded.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map<String, dynamic>))));
print('[CACHE] Loaded ${conversations.length} conversations instantly.');
}
} catch (e) {
@@ -85,7 +97,7 @@ class ConversationsController extends GetxController {
final res = await _svc.getConversations();
if (res['type'] == 'conversations') {
final List<dynamic> data = res['data'] ?? [];
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
conversations.assignAll(data.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map<String, dynamic>))));
_saveConversationsToCache(data);
} else {
errorMessage.value = res['message'] ?? 'Failed to load conversations';
@@ -189,7 +201,7 @@ class ConversationsController extends GetxController {
final res = await _svc.getConversations();
if (res['type'] == 'conversations') {
final List<dynamic> data = res['data'] ?? [];
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
conversations.assignAll(data.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map<String, dynamic>))));
_saveConversationsToCache(data);
}
} catch (_) {}

View File

@@ -4,6 +4,7 @@ import 'package:get/get.dart';
import 'package:firebase_core/firebase_core.dart';
import 'services/whatsapp_service.dart';
import 'services/firebase_service.dart';
import 'services/contacts_service.dart';
import 'screens/conversations_screen.dart';
import 'theme/app_theme.dart';
@@ -23,8 +24,12 @@ void main() async {
));
// Register services before app starts
Get.put(ContactsService(), permanent: true);
Get.put(WhatsAppService(), permanent: true);
Get.put(FirebaseService(), permanent: true);
// Initialize Contacts Service
await Get.find<ContactsService>().init();
Get.find<FirebaseService>().init();
runApp(const WhatsAppApp());

View File

@@ -0,0 +1,85 @@
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:get/get.dart';
class ContactInfo {
final String name;
final String? avatarPath;
ContactInfo({required this.name, this.avatarPath});
}
class ContactsService extends GetxService {
final RxMap<String, ContactInfo> _contactsMap = <String, ContactInfo>{}.obs;
final RxBool permissionGranted = false.obs;
Future<ContactsService> init() async {
await fetchContacts();
return this;
}
Future<void> fetchContacts() async {
try {
// Check and request permission
bool permission = await FlutterContacts.requestPermission(readonly: true);
permissionGranted.value = permission;
if (permission) {
// Fetch contacts with photos and phone numbers
final contacts = await FlutterContacts.getContacts(withProperties: true, withPhoto: true);
final Map<String, ContactInfo> tempMap = {};
for (var contact in contacts) {
final fullName = contact.displayName;
if (fullName.isEmpty) continue;
for (var phone in contact.phones) {
final normalized = normalizePhoneNumber(phone.number);
if (normalized.isNotEmpty) {
tempMap[normalized] = ContactInfo(
name: fullName,
avatarPath: null, // Custom local avatar path can be handled if needed
);
}
}
}
_contactsMap.assignAll(tempMap);
print('[CONTACTS] Successfully loaded ${_contactsMap.length} normalized phone contacts');
}
} catch (e) {
print('[CONTACTS ERROR] Failed to fetch system contacts: $e');
}
}
// Normalizes numbers to match them easily (e.g., removes spaces, dashes, brackets, and leading zeros)
String normalizePhoneNumber(String number) {
String clean = number.replaceAll(RegExp(r'[\s\-\(\)\+]'), '');
// If it starts with local country prefix or leading 0, we can do substring matches
if (clean.startsWith('00')) {
clean = clean.substring(2);
}
return clean;
}
String getContactName(String rawNumber, String fallback) {
if (!permissionGranted.value || _contactsMap.isEmpty) return fallback;
final clean = normalizePhoneNumber(rawNumber);
if (clean.isEmpty) return fallback;
// Direct match
if (_contactsMap.containsKey(clean)) {
return _contactsMap[clean]!.name;
}
// Partial match for varying country codes (match last 9 digits of the phone number)
if (clean.length >= 9) {
final suffix = clean.substring(clean.length - 9);
for (var key in _contactsMap.keys) {
if (key.endsWith(suffix)) {
return _contactsMap[key]!.name;
}
}
}
return fallback;
}
}

View File

@@ -215,6 +215,28 @@ class WhatsAppService extends GetxService {
Future<Map<String, dynamic>> sendFcmToken(String token) =>
_request({ 'type': 'register_fcm', 'token': token });
Future<Map<String, dynamic>> getMedia(String messageId) =>
_request({ 'type': 'get_media', 'messageId': messageId });
// Cache downloaded media: messageId -> base64
final RxMap<String, String> mediaCache = <String, String>{}.obs;
Future<String?> downloadAndCacheMedia(String messageId) async {
if (mediaCache.containsKey(messageId)) return mediaCache[messageId];
try {
final res = await getMedia(messageId);
if (res['type'] == 'media' && res['data'] != null) {
final String base64Data = res['data'];
mediaCache[messageId] = base64Data;
return base64Data;
}
} catch (e) {
print('[MEDIA DOWNLOAD ERROR] Failed to download message media: $e');
}
return null;
}
Future<Map<String, dynamic>> ping() =>
_request({ 'type': 'ping' });
}

View File

@@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:get/get.dart';
import '../models/message_model.dart';
import '../theme/app_theme.dart';
import '../services/whatsapp_service.dart';
class MessageBubble extends StatelessWidget {
final MessageModel message;
@@ -58,9 +61,9 @@ class MessageBubble extends StatelessWidget {
const SizedBox(height: 4),
],
// Media placeholder if it is media
// Interactive Media widget if message has media
if (message.hasMedia) ...[
_buildMediaPlaceholder(),
InteractiveMediaWidget(message: message),
const SizedBox(height: 6),
],
@@ -108,49 +111,6 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildMediaPlaceholder() {
IconData iconData = Icons.insert_drive_file;
String label = "File Attachment";
switch (message.type) {
case "image":
iconData = Icons.photo;
label = "Image";
break;
case "video":
iconData = Icons.videocam;
label = "Video";
break;
case "audio":
iconData = Icons.audiotrack;
label = "Audio File";
break;
case "sticker":
iconData = Icons.emoji_emotions;
label = "Sticker";
break;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(iconData, color: AppTheme.textSecondary, size: 32),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _buildAckIcon(int ack) {
switch (ack) {
case 1: // Pending
@@ -172,3 +132,184 @@ class MessageBubble extends StatelessWidget {
return DateFormat('h:mm a').format(dt);
}
}
class InteractiveMediaWidget extends StatefulWidget {
final MessageModel message;
const InteractiveMediaWidget({super.key, required this.message});
@override
State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState();
}
class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
final WhatsAppService _svc = Get.find<WhatsAppService>();
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return Obx(() {
final cachedMedia = _svc.mediaCache[widget.message.id];
if (cachedMedia != null) {
return _buildDownloadedMedia(cachedMedia);
}
if (_isLoading) {
return Container(
padding: const EdgeInsets.all(16),
width: 140,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
),
);
}
// Tap to download media placeholder
return GestureDetector(
onTap: _downloadMedia,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_getIcon(), color: AppTheme.textSecondary, size: 32),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getLabel(),
style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500, fontSize: 13),
),
const SizedBox(height: 2),
const Text(
'Tap to download',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 10),
),
],
),
],
),
),
);
});
}
Future<void> _downloadMedia() async {
setState(() => _isLoading = true);
await _svc.downloadAndCacheMedia(widget.message.id);
if (mounted) {
setState(() => _isLoading = false);
}
}
Widget _buildDownloadedMedia(String base64Data) {
final bytes = base64Decode(base64Data);
if (widget.message.type == "image" || widget.message.type == "sticker") {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
bytes,
fit: BoxFit.cover,
maxHeight: 250,
width: double.infinity,
),
);
}
if (widget.message.type == "audio") {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow, color: AppTheme.primary, size: 24),
onPressed: () {
Get.snackbar(
'Audio Playback',
'Playing voice note/audio file...',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppTheme.surfaceLight,
colorText: AppTheme.textPrimary,
);
},
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: LinearProgressIndicator(
value: 0.0,
backgroundColor: AppTheme.surfaceLight,
color: AppTheme.primary,
),
),
),
const Text(
'Voice Note',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11),
),
],
),
);
}
// Default download complete file placeholder
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline, color: AppTheme.primary, size: 32),
const SizedBox(width: 12),
Text(
_getLabel(),
style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500),
),
],
),
);
}
IconData _getIcon() {
switch (widget.message.type) {
case "image": return Icons.photo_outlined;
case "video": return Icons.videocam_outlined;
case "audio": return Icons.audiotrack_outlined;
case "sticker": return Icons.emoji_emotions_outlined;
default: return Icons.insert_drive_file_outlined;
}
}
String _getLabel() {
switch (widget.message.type) {
case "image": return "Image Attachment";
case "video": return "Video Attachment";
case "audio": return "Audio / Voice Note";
case "sticker": return "Sticker Attachment";
default: return "File Attachment";
}
}
}

View File

@@ -206,6 +206,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_contacts:
dependency: "direct main"
description:
name: flutter_contacts
sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae"
url: "https://pub.dev"
source: hosted
version: "1.1.9+2"
flutter_lints:
dependency: "direct dev"
description:

View File

@@ -20,6 +20,7 @@ dependencies:
firebase_core: ^2.31.1
firebase_messaging: ^14.9.1
flutter_local_notifications: ^17.1.2
flutter_contacts: ^1.1.7
dev_dependencies:
flutter_test:

View File

@@ -59,18 +59,23 @@ function sendTo(ws, payload) {
async function formatChat(chat) {
let avatar = null;
try {
// Ultra-fast memory-based avatar fetch with strict 300ms fallback to prevent hangs
avatar = await Promise.race([
chat.client.pupPage.evaluate((chatId) => {
// 1. Try memory-based avatar lookup first (takes < 1ms)
avatar = await chat.client.pupPage.evaluate((chatId) => {
try {
const contact = window.Store.Contact.get(chatId);
return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null;
} catch (_) {
return null;
}
}, chat.id._serialized),
new Promise(resolve => setTimeout(() => resolve(null), 300))
}, chat.id._serialized);
// 2. If memory has no avatar, fallback to strict-timeout network query (max 800ms)
if (!avatar) {
avatar = await Promise.race([
chat.getProfilePicUrl().catch(() => null),
new Promise(resolve => setTimeout(() => resolve(null), 800))
]);
}
} catch (_) {}
// Last Message formatting
@@ -355,6 +360,53 @@ async function handleMessage(ws, raw) {
});
}
// ── Media ──────────────────────────────────────────────────────────
case 'get_media': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const { messageId } = payload;
if (!messageId) {
return respond({ type: 'error', message: 'messageId is required' });
}
try {
// Extract chatId from messageId (format: true_447701407332@c.us_3EB0C8B196C5F354)
const parts = messageId.split('_');
if (parts.length < 2) {
return respond({ type: 'error', message: 'Invalid messageId format' });
}
const chatId = parts[1];
const chat = await waClient.getChatById(chatId);
const messages = await chat.fetchMessages({ limit: 100 });
const msg = messages.find(m => m.id._serialized === messageId);
if (!msg) {
return respond({ type: 'error', message: 'Message not found in chat history' });
}
if (!msg.hasMedia) {
return respond({ type: 'error', message: 'Message has no media attachments' });
}
console.log(`[WS] Downloading media for message: ${messageId}`);
const media = await msg.downloadMedia();
if (!media) {
return respond({ type: 'error', message: 'Failed to download media file from WhatsApp servers' });
}
return respond({
type: 'media',
messageId: messageId,
data: media.data, // base64 string
mimetype: media.mimetype,
filename: media.filename || 'file',
requestId
});
} catch (err) {
console.error('[WS] get_media failed:', err.message);
return respond({ type: 'error', message: err.message || 'Failed to download media', requestId });
}
}
// ── Send Message ───────────────────────────────────────────────────
case 'send_message': {
if (!clientReady) {