Sync update: 2026-05-18 16:14:25
This commit is contained in:
@@ -66,5 +66,7 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSContactsUsageDescription</key>
|
||||||
|
<string>This app requires contacts access to match phone numbers with your local address book names.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../services/whatsapp_service.dart';
|
import '../services/whatsapp_service.dart';
|
||||||
|
import '../services/contacts_service.dart';
|
||||||
import '../models/conversation_model.dart';
|
import '../models/conversation_model.dart';
|
||||||
|
|
||||||
class ConversationsController extends GetxController {
|
class ConversationsController extends GetxController {
|
||||||
@@ -49,6 +50,17 @@ class ConversationsController extends GetxController {
|
|||||||
super.onClose();
|
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 ────────────────────────────────────────────────────────
|
// ── Local Caching ────────────────────────────────────────────────────────
|
||||||
Future<void> _loadCachedConversations() async {
|
Future<void> _loadCachedConversations() async {
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +68,7 @@ class ConversationsController extends GetxController {
|
|||||||
final cached = prefs.getString('cached_conversations');
|
final cached = prefs.getString('cached_conversations');
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
final List<dynamic> decoded = jsonDecode(cached);
|
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.');
|
print('[CACHE] Loaded ${conversations.length} conversations instantly.');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -85,7 +97,7 @@ class ConversationsController extends GetxController {
|
|||||||
final res = await _svc.getConversations();
|
final res = await _svc.getConversations();
|
||||||
if (res['type'] == 'conversations') {
|
if (res['type'] == 'conversations') {
|
||||||
final List<dynamic> data = res['data'] ?? [];
|
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);
|
_saveConversationsToCache(data);
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = res['message'] ?? 'Failed to load conversations';
|
errorMessage.value = res['message'] ?? 'Failed to load conversations';
|
||||||
@@ -189,7 +201,7 @@ class ConversationsController extends GetxController {
|
|||||||
final res = await _svc.getConversations();
|
final res = await _svc.getConversations();
|
||||||
if (res['type'] == 'conversations') {
|
if (res['type'] == 'conversations') {
|
||||||
final List<dynamic> data = res['data'] ?? [];
|
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);
|
_saveConversationsToCache(data);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:get/get.dart';
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'services/whatsapp_service.dart';
|
import 'services/whatsapp_service.dart';
|
||||||
import 'services/firebase_service.dart';
|
import 'services/firebase_service.dart';
|
||||||
|
import 'services/contacts_service.dart';
|
||||||
import 'screens/conversations_screen.dart';
|
import 'screens/conversations_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
@@ -23,8 +24,12 @@ void main() async {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Register services before app starts
|
// Register services before app starts
|
||||||
|
Get.put(ContactsService(), permanent: true);
|
||||||
Get.put(WhatsAppService(), permanent: true);
|
Get.put(WhatsAppService(), permanent: true);
|
||||||
Get.put(FirebaseService(), permanent: true);
|
Get.put(FirebaseService(), permanent: true);
|
||||||
|
|
||||||
|
// Initialize Contacts Service
|
||||||
|
await Get.find<ContactsService>().init();
|
||||||
Get.find<FirebaseService>().init();
|
Get.find<FirebaseService>().init();
|
||||||
|
|
||||||
runApp(const WhatsAppApp());
|
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) =>
|
Future<Map<String, dynamic>> sendFcmToken(String token) =>
|
||||||
_request({ 'type': 'register_fcm', 'token': 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() =>
|
Future<Map<String, dynamic>> ping() =>
|
||||||
_request({ 'type': 'ping' });
|
_request({ 'type': 'ping' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import '../models/message_model.dart';
|
import '../models/message_model.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../services/whatsapp_service.dart';
|
||||||
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
@@ -58,9 +61,9 @@ class MessageBubble extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Media placeholder if it is media
|
// Interactive Media widget if message has media
|
||||||
if (message.hasMedia) ...[
|
if (message.hasMedia) ...[
|
||||||
_buildMediaPlaceholder(),
|
InteractiveMediaWidget(message: message),
|
||||||
const SizedBox(height: 6),
|
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) {
|
Widget _buildAckIcon(int ack) {
|
||||||
switch (ack) {
|
switch (ack) {
|
||||||
case 1: // Pending
|
case 1: // Pending
|
||||||
@@ -172,3 +132,184 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return DateFormat('h:mm a').format(dt);
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dependencies:
|
|||||||
firebase_core: ^2.31.1
|
firebase_core: ^2.31.1
|
||||||
firebase_messaging: ^14.9.1
|
firebase_messaging: ^14.9.1
|
||||||
flutter_local_notifications: ^17.1.2
|
flutter_local_notifications: ^17.1.2
|
||||||
|
flutter_contacts: ^1.1.7
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -59,18 +59,23 @@ function sendTo(ws, payload) {
|
|||||||
async function formatChat(chat) {
|
async function formatChat(chat) {
|
||||||
let avatar = null;
|
let avatar = null;
|
||||||
try {
|
try {
|
||||||
// Ultra-fast memory-based avatar fetch with strict 300ms fallback to prevent hangs
|
// 1. Try memory-based avatar lookup first (takes < 1ms)
|
||||||
avatar = await Promise.race([
|
avatar = await chat.client.pupPage.evaluate((chatId) => {
|
||||||
chat.client.pupPage.evaluate((chatId) => {
|
try {
|
||||||
try {
|
const contact = window.Store.Contact.get(chatId);
|
||||||
const contact = window.Store.Contact.get(chatId);
|
return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null;
|
||||||
return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null;
|
} catch (_) {
|
||||||
} catch (_) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
}, chat.id._serialized);
|
||||||
}, chat.id._serialized),
|
|
||||||
new Promise(resolve => setTimeout(() => resolve(null), 300))
|
// 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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Last Message formatting
|
// 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 ───────────────────────────────────────────────────
|
// ── Send Message ───────────────────────────────────────────────────
|
||||||
case 'send_message': {
|
case 'send_message': {
|
||||||
if (!clientReady) {
|
if (!clientReady) {
|
||||||
|
|||||||
Reference in New Issue
Block a user