Sync update: 2026-05-18 16:14:25
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 (_) {}
|
||||
|
||||
@@ -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());
|
||||
|
||||
85
whatsapp_app/lib/services/contacts_service.dart
Normal file
85
whatsapp_app/lib/services/contacts_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => {
|
||||
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))
|
||||
]);
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user