Initial commit with Flutter and Node.js code

This commit is contained in:
Hamza-Ayed
2026-05-18 14:04:39 +03:00
commit a60a173b51
21 changed files with 3107 additions and 0 deletions

3
.gitconfig Normal file
View File

@@ -0,0 +1,3 @@
[safe]
directory = /Users/hamzaaleghwairyeen/development/flutter
directory = /Users/hamzaaleghwairyeen/flutter

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Node.js Backend
whatsapp_bridge/node_modules/
whatsapp_bridge/.wwebjs_auth/
whatsapp_bridge/.wwebjs_cache/
whatsapp_bridge/.puppeteer/
whatsapp_bridge/package-lock.json
# Flutter / Dart
whatsapp_app/.dart_tool/
whatsapp_app/.flutter-plugins
whatsapp_app/.flutter-plugins-dependencies
whatsapp_app/.packages
whatsapp_app/build/
whatsapp_app/ios/Pods/
whatsapp_app/ios/.symlinks/
whatsapp_app/ios/Flutter/Flutter.framework/
whatsapp_app/ios/Flutter/Flutter.podspec
whatsapp_app/android/.gradle/
whatsapp_app/android/app/build/
whatsapp_app/android/local.properties
# OS/IDE
.DS_Store
.idea/
.vscode/
*.swp

307
README.md Normal file
View File

@@ -0,0 +1,307 @@
# WhatsApp Mirror — Full-Stack Real-Time Bridge
A production-grade, highly-responsive WhatsApp Mirror application. This system allows you to remotely view and manage a WhatsApp account on a dark-themed Flutter mobile app (iOS + Android) by bridging commands and real-time events over a standalone WebSockets Node.js bridge server.
---
## 🏗️ Architecture
```
📱 Mobile Client (Flutter) ⚡ [WebSockets Protocol] ⚡ 🖥️ standalone Backend Server (Node.js) ⚙️ [Puppeteer] ⚙️ 🟩 WhatsApp Web (LocalAuth Session)
```
---
## 📂 Project Structure
```
whatsapp-app/
├── README.md ← Full system configuration & documentation
├── whatsapp_bridge/ ← standalone Node.js server
│ ├── package.json ← Package dependencies (whatsapp-web.js, ws, qrcode, express)
│ ├── server.js ← Bridge server entrypoint (Port 3025)
│ └── .wwebjs_auth/ ← Session persistence auth cache (git-ignored)
└── whatsapp_app/ ← Dark-themed Flutter Client
├── pubspec.yaml ← Flutter configuration and plugins
└── lib/
├── main.dart ← Flutter app entry point (GetX service initializer)
├── config/
│ └── app_config.dart ← Server host & port variables
├── theme/
│ └── app_theme.dart ← WhatsApp-style immersive Dark Theme
├── services/
│ └── whatsapp_service.dart ← Websocket request/response manager
├── models/
│ ├── conversation_model.dart
│ └── message_model.dart
├── controllers/
│ ├── conversations_controller.dart
│ └── chat_controller.dart
├── screens/
│ ├── conversations_screen.dart
│ ├── chat_screen.dart
│ └── qr_screen.dart
└── widgets/
├── conversation_tile.dart
└── message_bubble.dart
```
---
## ⚡ WebSockets Protocol Specification
To handle asynchronous requests and map them back to specific UI components or triggers, a strict **Request-Response ID matching mechanism** is implemented.
### 1. Client-to-Server Requests
Every request from the Flutter client MUST contain a unique `requestId`. The server replies with the exact same `requestId`.
#### A. Ping Check (`ping`)
* **Request:**
```json
{ "type": "ping", "requestId": "1" }
```
* **Response:**
```json
{ "type": "pong", "ready": true, "requestId": "1" }
```
#### B. Get Conversation List (`get_conversations`)
* **Request:**
```json
{ "type": "get_conversations", "limit": 50, "offset": 0, "requestId": "2" }
```
* **Response:**
```json
{
"type": "conversations",
"data": [
{
"id": "1234567890@c.us",
"name": "Jane Doe",
"isGroup": false,
"unreadCount": 2,
"avatar": "https://pps.whatsapp.net/v/...",
"lastMessage": {
"body": "Hello there!",
"timestamp": 1716035000,
"fromMe": false,
"hasMedia": false
},
"timestamp": 1716035000,
"pinned": false,
"isMuted": false
}
],
"total": 1,
"requestId": "2"
}
```
#### C. Get Message History (`get_messages`)
* **Request:**
```json
{ "type": "get_messages", "chatId": "1234567890@c.us", "limit": 50, "requestId": "3" }
```
* **Response:**
```json
{
"type": "messages",
"chatId": "1234567890@c.us",
"data": [
{
"id": "true_1234567890@c.us_ABC123",
"body": "Hello there!",
"fromMe": false,
"timestamp": 1716035000,
"type": "chat",
"hasMedia": false,
"isForwarded": false,
"author": null,
"ack": 4
}
],
"requestId": "3"
}
```
#### D. Send Message (`send_message`)
* **Request:**
```json
{ "type": "send_message", "chatId": "1234567890@c.us", "text": "Hello!", "requestId": "4" }
```
* **Response:**
```json
{
"type": "message_sent",
"chatId": "1234567890@c.us",
"data": {
"id": "true_1234567890@c.us_XYZ987",
"body": "Hello!",
"fromMe": true,
"timestamp": 1716035005,
"type": "chat",
"hasMedia": false,
"isForwarded": false,
"author": null,
"ack": 1
},
"requestId": "4"
}
```
#### E. Mark Chat as Read (`mark_read`)
* **Request:**
```json
{ "type": "mark_read", "chatId": "1234567890@c.us", "requestId": "5" }
```
* **Response:**
```json
{ "type": "marked_read", "chatId": "1234567890@c.us", "requestId": "5" }
```
#### F. Search Conversations (`search_conversations`)
* **Request:**
```json
{ "type": "search_conversations", "query": "Jane", "requestId": "6" }
```
* **Response:**
```json
{
"type": "conversations",
"data": [...],
"search": true,
"requestId": "6"
}
```
---
### 2. Server-to-Client Push Events
The server broadcasts live events to all connected clients immediately.
* **QR Code Broadcast:**
```json
{ "type": "qr", "qr": "data:image/png;base64,iVBORw0KGgo..." }
```
* **Authenticated:**
```json
{ "type": "authenticated" }
```
* **Client Ready:**
```json
{ "type": "ready" }
```
* **Status Updates:**
```json
{ "type": "status", "ready": true }
```
* **Client Disconnected:**
```json
{ "type": "disconnected", "reason": "Session expired or logged out" }
```
* **New Incoming Message:**
```json
{
"type": "new_message",
"chatId": "1234567890@c.us",
"data": {
"id": "false_1234567890@c.us_DEF456",
"body": "Live incoming text!",
"fromMe": false,
"timestamp": 1716035100,
"type": "chat",
"hasMedia": false,
"isForwarded": false,
"author": null,
"ack": 2
}
}
```
* **Message Delivery / Read Receipt (`message_ack`):**
```json
{
"type": "message_ack",
"messageId": "true_1234567890@c.us_XYZ987",
"chatId": "1234567890@c.us",
"ack": 4
}
```
*(Ack codes: 0 = Error/None, 1 = Pending, 2 = Sent, 3 = Delivered, 4 = Read)*
---
## 🚀 Quick Setup & Installation
### 1. Server Setup (`whatsapp_bridge/`)
Navigate into the backend project, install dependencies, and start the standalone service:
```bash
cd whatsapp_bridge
npm install
node server.js
```
The server will boot up and bind to **Port 3025**. It will automatically print:
`[SERVER] Standalone WhatsApp Bridge running on port 3025`
### 2. Flutter App Setup (`whatsapp_app/`)
First, ensure that you have created the Flutter project structure using standard platform templates:
```bash
# Run this inside the workspace directory
flutter create whatsapp_app
```
Then, copy all files in `whatsapp_app/` into the newly created project folder. Open the folder and install Dart packages:
```bash
cd whatsapp_app
flutter pub get
```
#### Run on Simulator / Device
```bash
flutter run
```
---
## 🌐 Production Deployment (CloudPanel Server)
Since this backend server acts as a standalone daemon and does not conflict with existing apps, it operates exclusively on **Port 3025**.
### 1. Create Node.js Site in CloudPanel
1. Navigate to **CloudPanel Admin Portal** -> **Sites** -> **Add Site** -> **Create a Node.js Application**.
2. Set Domain Name: `mywhatsappapp.interlap.com` (or your subdomain).
3. Set Port: `3025`.
4. Set Entry Point: `server.js`.
5. Select Node.js Version: `18+ LTS` (or Node 20 LTS).
### 2. Bypass Nginx Reverse Proxy
By default, CloudPanel routes external traffic from Port 80/443 through an Nginx reverse proxy. For our standalone WebSockets configuration, we want direct access.
Make sure to open **Port 3025** on your firewall (e.g. AWS Security Group or UFW):
```bash
sudo ufw allow 3025/tcp
```
This isolates Node.js directly on port 3025.
### 3. Keep Server Alive with PM2
We highly recommend running your Node.js application inside PM2 to ensure it auto-reconnects, logs diagnostic crashes, and recovers seamlessly:
```bash
# Install PM2 globally
npm install -g pm2
# Start the bridge server
pm2 start server.js --name "whatsapp-bridge"
# Persist server launch on reboot
pm2 save
pm2 startup
```
---
## 🔒 Security & Performance Features
* **Session Persistence:** Configured using `whatsapp-web.js`'s built-in `LocalAuth` session strategies, meaning that once you scan the QR code once, you will not have to scan it again unless explicitly logged out.
* **Resilience & Crash Prevention:** The server wraps critical events in structured `try/catch` clauses and binds events to `process.on('uncaughtException')`, protecting the server from unexpected Puppeteer execution crashes.
* **Auto-Reconnection:** If WhatsApp Web loses connection or the user logs out, the server attempts re-initialization automatically after a 5-second backoff delay.
* **Dark Mode Design:** Beautiful custom-tailored dark theme matching official WhatsApp guidelines (`#111B21`, `#1F2C34`, `#00A884`) with full custom ripple alerts, status badges, and tick indicators.

36
push_to_server.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
echo "🚀 Starting sync to Git repository..."
# Check if git is initialized
if [ ! -d ".git" ]; then
git init
git branch -M main
echo "✅ Git repository initialized."
fi
# Add all files respecting .gitignore
git add .
# Commit with current timestamp
COMMIT_MSG="Sync update: $(date +'%Y-%m-%d %H:%M:%S')"
git commit -m "$COMMIT_MSG"
echo "✅ Committed with message: $COMMIT_MSG"
# Ensure the user has added a remote origin before pushing
REMOTE=$(git remote -v)
if [ -z "$REMOTE" ]; then
echo "⚠️ No remote origin found."
echo "Please add your GitHub/Git repository URL first by running:"
echo "git remote add origin YOUR_GIT_URL"
echo "Then run this script again."
exit 1
fi
# Push to the current branch
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "📤 Pushing to branch: $BRANCH..."
git push origin "$BRANCH"
echo "✅ Sync complete! You can now run 'git pull' on your server."

View File

@@ -0,0 +1,8 @@
class AppConfig {
static const String serverHost = "mywhatsappapp.interlap.com";
static const int serverPort = 3025;
static const String wsUrl = "ws://$serverHost:$serverPort";
static const int maxReconnectAttempts = 10;
static const Duration reconnectDelay = Duration(seconds: 3);
}

View File

@@ -0,0 +1,185 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../services/whatsapp_service.dart';
import '../models/conversation_model.dart';
import '../models/message_model.dart';
class ChatController extends GetxController {
final ConversationModel conversation;
final WhatsAppService _svc = Get.find<WhatsAppService>();
final messages = <MessageModel>[].obs;
final isLoading = false.obs;
final isSending = false.obs;
final inputCtrl = TextEditingController();
final scrollCtrl = ScrollController();
StreamSubscription? _eventSub;
ChatController({required this.conversation});
@override
void onInit() {
super.onInit();
loadMessages();
markAsRead();
// Listen to push events for new messages and message delivery updates
_eventSub = _svc.events.listen(_onPushEvent);
}
@override
void onClose() {
_eventSub?.cancel();
inputCtrl.dispose();
scrollCtrl.dispose();
super.onClose();
}
// ── Load Messages ────────────────────────────────────────────────────────
Future<void> loadMessages() async {
isLoading.value = true;
try {
final res = await _svc.getMessages(conversation.id);
if (res['type'] == 'messages') {
final List<dynamic> data = res['data'] ?? [];
final fetched = data.map((m) => MessageModel.fromJson(m as Map<String, dynamic>)).toList();
// Sort chronologically (oldest to newest)
fetched.sort((a, b) => a.timestamp.compareTo(b.timestamp));
messages.assignAll(fetched);
// Scroll to bottom after list is rendered
_scrollToBottom();
}
} catch (e) {
print('[LOAD MESSAGES ERROR] $e');
} finally {
isLoading.value = false;
}
}
// ── Send Message ─────────────────────────────────────────────────────────
Future<void> sendMessage() async {
final text = inputCtrl.text.trim();
if (text.isEmpty || isSending.value) return;
isSending.value = true;
inputCtrl.clear();
try {
final res = await _svc.sendMessage(conversation.id, text);
if (res['type'] == 'message_sent') {
final sentMsg = MessageModel.fromJson(res['data'] as Map<String, dynamic>);
messages.add(sentMsg);
_scrollToBottom();
}
} catch (e) {
print('[SEND MESSAGE ERROR] $e');
Get.snackbar('Error', 'Failed to send message: $e',
backgroundColor: Colors.redAccent.withOpacity(0.8),
colorText: Colors.white,
);
} finally {
isSending.value = false;
}
}
// ── Mark Chat as Read ────────────────────────────────────────────────────
Future<void> markAsRead() async {
try {
await _svc.markRead(conversation.id);
} catch (e) {
print('[MARK READ ERROR] $e');
}
}
// ── Push Event Handler ───────────────────────────────────────────────────
void _onPushEvent(Map<String, dynamic> event) {
final type = event['type'] as String?;
if (type == null) return;
switch (type) {
case 'new_message':
final chatId = event['chatId'] as String?;
final msgData = event['data'] as Map<String, dynamic>?;
if (chatId == null || msgData == null) return;
// If the new message is for this chat
if (chatId == conversation.id) {
final newMsg = MessageModel.fromJson(msgData);
// Prevent duplicates just in case
if (!messages.any((m) => m.id == newMsg.id)) {
messages.add(newMsg);
_scrollToBottom();
markAsRead(); // Mark as read since user is actively viewing
}
}
break;
case 'message_ack':
final messageId = event['messageId'] as String?;
final chatId = event['chatId'] as String?;
final ack = event['ack'] as int?;
if (chatId == null || messageId == null || ack == null) return;
if (chatId == conversation.id) {
final index = messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
messages[index] = messages[index].copyWith(ack: ack);
}
}
break;
}
}
// ── Helper: Scroll to Bottom ─────────────────────────────────────────────
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollCtrl.hasClients) {
scrollCtrl.animateTo(
scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
// ── Date Separator Logic ─────────────────────────────────────────────────
List<dynamic> get groupedMessages {
final list = <dynamic>[];
if (messages.isEmpty) return list;
String? lastDate;
for (final msg in messages) {
final date = _formatDateSeparator(msg.timestamp);
if (date != lastDate) {
list.add(date);
lastDate = date;
}
list.add(msg);
}
return list;
}
String _formatDateSeparator(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final msgDate = DateTime(dt.year, dt.month, dt.day);
if (msgDate == today) {
return 'Today';
} else if (msgDate == yesterday) {
return 'Yesterday';
} else {
return DateFormat('MMMM d, yyyy').format(dt);
}
}
}

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'package:get/get.dart';
import '../services/whatsapp_service.dart';
import '../models/conversation_model.dart';
class ConversationsController extends GetxController {
final WhatsAppService _svc = Get.find<WhatsAppService>();
final conversations = <ConversationModel>[].obs;
final isLoading = false.obs;
final errorMessage = Rx<String?>(null);
StreamSubscription? _eventSub;
StreamSubscription? _readySub;
Timer? _searchDebounce;
@override
void onInit() {
super.onInit();
// Load conversations initially if already ready
if (_svc.isWaReady.value) {
loadConversations();
}
// React to WhatsApp ready status changes
_readySub = _svc.isWaReady.listen((ready) {
if (ready) {
loadConversations();
} else {
conversations.clear();
}
});
// Listen to push events from the server
_eventSub = _svc.events.listen(_onPushEvent);
}
@override
void onClose() {
_eventSub?.cancel();
_readySub?.cancel();
_searchDebounce?.cancel();
super.onClose();
}
// ── Load Conversations ───────────────────────────────────────────────────
Future<void> loadConversations() async {
if (!_svc.isWaReady.value) return;
isLoading.value = true;
errorMessage.value = null;
try {
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>)));
} else {
errorMessage.value = res['message'] ?? 'Failed to load conversations';
}
} catch (e) {
errorMessage.value = e.toString();
} finally {
isLoading.value = false;
}
}
// ── Search Conversations ──────────────────────────────────────────────────
void search(String query) {
_searchDebounce?.cancel();
if (query.trim().isEmpty) {
loadConversations();
return;
}
_searchDebounce = Timer(const Duration(milliseconds: 400), () async {
isLoading.value = true;
try {
final res = await _svc.searchConversations(query);
if (res['type'] == 'conversations') {
final List<dynamic> data = res['data'] ?? [];
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
}
} catch (e) {
print('[SEARCH ERROR] $e');
} finally {
isLoading.value = false;
}
});
}
// ── Handle Incoming Socket Push Events ──────────────────────────────────
void _onPushEvent(Map<String, dynamic> event) {
final type = event['type'] as String?;
if (type == null) return;
switch (type) {
case 'new_message':
final chatId = event['chatId'] as String?;
final msgData = event['data'] as Map<String, dynamic>?;
if (chatId == null || msgData == null) return;
// Create the LastMessage object
final lastMsg = LastMessageModel(
body: msgData['body'] ?? '',
timestamp: msgData['timestamp'] ?? 0,
fromMe: msgData['fromMe'] ?? false,
hasMedia: msgData['hasMedia'] ?? false,
);
// Find existing conversation and update it
final index = conversations.indexWhere((c) => c.id == chatId);
if (index != -1) {
final existing = conversations[index];
final updated = existing.copyWith(
lastMessage: lastMsg,
timestamp: lastMsg.timestamp,
unreadCount: lastMsg.fromMe ? existing.unreadCount : existing.unreadCount + 1,
);
conversations.removeAt(index);
conversations.insert(0, updated);
} else {
// If conversation is not loaded, trigger a silent full reload to fetch it
loadConversationsSilently();
}
break;
case 'message_ack':
final messageId = event['messageId'] as String?;
final chatId = event['chatId'] as String?;
final ack = event['ack'] as int?;
if (chatId == null || messageId == null || ack == null) return;
// If the last message in a conversation was acknowledged, update it
final index = conversations.indexWhere((c) => c.id == chatId);
if (index != -1) {
// We can refresh silently if it is the current conversation's last message.
// Since ack is simple, a quick silent refresh guarantees correct ack state.
loadConversationsSilently();
}
break;
case 'ready':
case 'authenticated':
loadConversationsSilently();
break;
case 'disconnected':
conversations.clear();
break;
}
}
Future<void> loadConversationsSilently() async {
if (!_svc.isWaReady.value) return;
try {
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>)));
}
} catch (_) {}
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'services/whatsapp_service.dart';
import 'screens/conversations_screen.dart';
import 'theme/app_theme.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
));
// Register the WhatsApp WebSocket client service before app starts
Get.put(WhatsAppService(), permanent: true);
runApp(const WhatsAppApp());
}
class WhatsAppApp extends StatelessWidget {
const WhatsAppApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'WhatsApp App',
debugShowCheckedModeBanner: false,
theme: AppTheme.dark,
home: const ConversationsScreen(),
defaultTransition: Transition.cupertino,
);
}
}

View File

@@ -0,0 +1,109 @@
class LastMessageModel {
final String body;
final int timestamp;
final bool fromMe;
final bool hasMedia;
LastMessageModel({
required this.body,
required this.timestamp,
required this.fromMe,
required this.hasMedia,
});
factory LastMessageModel.fromJson(Map<String, dynamic> json) {
return LastMessageModel(
body: json['body'] ?? '',
timestamp: json['timestamp'] ?? 0,
fromMe: json['fromMe'] ?? false,
hasMedia: json['hasMedia'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'body': body,
'timestamp': timestamp,
'fromMe': fromMe,
'hasMedia': hasMedia,
};
}
}
class ConversationModel {
final String id;
final String name;
final bool isGroup;
final int unreadCount;
final String? avatar;
final LastMessageModel? lastMessage;
final int timestamp;
final bool pinned;
final bool isMuted;
ConversationModel({
required this.id,
required this.name,
required this.isGroup,
required this.unreadCount,
this.avatar,
this.lastMessage,
required this.timestamp,
required this.pinned,
required this.isMuted,
});
factory ConversationModel.fromJson(Map<String, dynamic> json) {
return ConversationModel(
id: json['id'] ?? '',
name: json['name'] ?? '',
isGroup: json['isGroup'] ?? false,
unreadCount: json['unreadCount'] ?? 0,
avatar: json['avatar'],
lastMessage: json['lastMessage'] != null
? LastMessageModel.fromJson(json['lastMessage'])
: null,
timestamp: json['timestamp'] ?? 0,
pinned: json['pinned'] ?? false,
isMuted: json['isMuted'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'isGroup': isGroup,
'unreadCount': unreadCount,
'avatar': avatar,
'lastMessage': lastMessage?.toJson(),
'timestamp': timestamp,
'pinned': pinned,
'isMuted': isMuted,
};
}
ConversationModel copyWith({
String? id,
String? name,
bool? isGroup,
int? unreadCount,
String? avatar,
LastMessageModel? lastMessage,
int? timestamp,
bool? pinned,
bool? isMuted,
}) {
return ConversationModel(
id: id ?? this.id,
name: name ?? this.name,
isGroup: isGroup ?? this.isGroup,
unreadCount: unreadCount ?? this.unreadCount,
avatar: avatar ?? this.avatar,
lastMessage: lastMessage ?? this.lastMessage,
timestamp: timestamp ?? this.timestamp,
pinned: pinned ?? this.pinned,
isMuted: isMuted ?? this.isMuted,
);
}
}

View File

@@ -0,0 +1,75 @@
class MessageModel {
final String id;
final String body;
final bool fromMe;
final int timestamp;
final String type; // "chat"|"image"|"video"|"audio"|"document"|"sticker"
final bool hasMedia;
final bool isForwarded;
final String? author;
final int ack; // 0=error/none 1=pending 2=sent 3=delivered 4=read
MessageModel({
required this.id,
required this.body,
required this.fromMe,
required this.timestamp,
required this.type,
required this.hasMedia,
required this.isForwarded,
this.author,
required this.ack,
});
factory MessageModel.fromJson(Map<String, dynamic> json) {
return MessageModel(
id: json['id'] ?? '',
body: json['body'] ?? '',
fromMe: json['fromMe'] ?? false,
timestamp: json['timestamp'] ?? 0,
type: json['type'] ?? 'chat',
hasMedia: json['hasMedia'] ?? false,
isForwarded: json['isForwarded'] ?? false,
author: json['author'],
ack: json['ack'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'body': body,
'fromMe': fromMe,
'timestamp': timestamp,
'type': type,
'hasMedia': hasMedia,
'isForwarded': isForwarded,
'author': author,
'ack': ack,
};
}
MessageModel copyWith({
String? id,
String? body,
bool? fromMe,
int? timestamp,
String? type,
bool? hasMedia,
bool? isForwarded,
String? author,
int? ack,
}) {
return MessageModel(
id: id ?? this.id,
body: body ?? this.body,
fromMe: fromMe ?? this.fromMe,
timestamp: timestamp ?? this.timestamp,
type: type ?? this.type,
hasMedia: hasMedia ?? this.hasMedia,
isForwarded: isForwarded ?? this.isForwarded,
author: author ?? this.author,
ack: ack ?? this.ack,
);
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/chat_controller.dart';
import '../models/conversation_model.dart';
import '../models/message_model.dart';
import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatelessWidget {
final ConversationModel conversation;
const ChatScreen({super.key, required this.conversation});
@override
Widget build(BuildContext context) {
final ctrl = Get.put(
ChatController(conversation: conversation),
tag: conversation.id,
);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: _buildAppBar(conversation),
body: Column(
children: [
Expanded(child: _buildMessageList(ctrl)),
_buildInputBar(ctrl),
],
),
);
}
AppBar _buildAppBar(ConversationModel chat) => AppBar(
backgroundColor: AppTheme.surface,
leadingWidth: 32,
title: Row(
children: [
_avatar(chat, radius: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
chat.name,
style: const TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
if (chat.isGroup)
const Text(
'Group',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.videocam_outlined, color: AppTheme.iconColor),
onPressed: null,
),
IconButton(
icon: const Icon(Icons.call_outlined, color: AppTheme.iconColor),
onPressed: null,
),
IconButton(
icon: const Icon(Icons.more_vert, color: AppTheme.iconColor),
onPressed: null,
),
],
);
Widget _buildMessageList(ChatController ctrl) {
return Obx(() {
if (ctrl.isLoading.value) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
final items = ctrl.groupedMessages;
if (items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, color: AppTheme.textSecondary.withOpacity(0.5), size: 48),
const SizedBox(height: 12),
Text(
'No messages yet',
style: TextStyle(color: AppTheme.textSecondary.withOpacity(0.8)),
),
],
),
);
}
return ListView.builder(
controller: ctrl.scrollCtrl,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
itemCount: items.length,
itemBuilder: (_, i) {
final item = items[i];
if (item is String) return _buildDateSeparator(item);
return MessageBubble(message: item as MessageModel);
},
);
});
}
Widget _buildDateSeparator(String label) => Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
);
Widget _buildInputBar(ChatController ctrl) => Container(
color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SafeArea(
child: Row(
children: [
// Emoji button
IconButton(
icon: const Icon(Icons.emoji_emotions_outlined, color: AppTheme.iconColor),
onPressed: null,
),
// Input
Expanded(
child: TextField(
controller: ctrl.inputCtrl,
style: const TextStyle(color: AppTheme.textPrimary),
maxLines: 5,
minLines: 1,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: 'Message',
hintStyle: const TextStyle(color: AppTheme.textSecondary),
filled: true,
fillColor: AppTheme.surfaceLight,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => ctrl.sendMessage(),
),
),
const SizedBox(width: 8),
// Send button
Obx(() => GestureDetector(
onTap: ctrl.sendMessage,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
height: 48,
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
child: ctrl.isSending.value
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.send, color: Colors.white, size: 20),
),
)),
],
),
),
);
Widget _avatar(ConversationModel chat, {double radius = 24}) {
if (chat.avatar != null) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(chat.avatar!),
backgroundColor: AppTheme.surfaceLight,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: AppTheme.primaryDark,
child: Text(
chat.name.isNotEmpty ? chat.name[0].toUpperCase() : '?',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/conversations_controller.dart';
import '../services/whatsapp_service.dart';
import '../theme/app_theme.dart';
import '../widgets/conversation_tile.dart';
import 'qr_screen.dart';
import 'chat_screen.dart';
import '../models/conversation_model.dart';
class ConversationsScreen extends StatelessWidget {
const ConversationsScreen({super.key});
@override
Widget build(BuildContext context) {
final svc = Get.find<WhatsAppService>();
final ctrl = Get.put(ConversationsController());
return Scaffold(
backgroundColor: AppTheme.background,
appBar: _buildAppBar(ctrl),
body: Obx(() {
// Not connected
if (svc.status.value == WsStatus.disconnected ||
svc.status.value == WsStatus.connecting) {
return _buildConnecting();
}
// QR Code needed
if (svc.qrData.value != null) {
return const QrView();
}
// Loading conversations
if (ctrl.isLoading.value) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
// Error
if (ctrl.errorMessage.value != null) {
return _buildError(ctrl);
}
// Empty
if (ctrl.conversations.isEmpty) {
return _buildEmpty();
}
// List
return _buildList(ctrl);
}),
);
}
AppBar _buildAppBar(ConversationsController ctrl) {
final searching = false.obs;
return AppBar(
backgroundColor: AppTheme.surface,
title: Obx(() => searching.value
? TextField(
autofocus: true,
style: const TextStyle(color: AppTheme.textPrimary),
decoration: const InputDecoration(
hintText: 'Search...',
border: InputBorder.none,
hintStyle: TextStyle(color: AppTheme.textSecondary),
),
onChanged: ctrl.search,
)
: const Text('WhatsApp', style: TextStyle(color: AppTheme.textPrimary))),
actions: [
Obx(() => IconButton(
icon: Icon(
searching.value ? Icons.close : Icons.search,
color: AppTheme.iconColor,
),
onPressed: () {
searching.value = !searching.value;
if (!searching.value) ctrl.loadConversations();
},
)),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: AppTheme.iconColor),
color: AppTheme.surface,
onSelected: (v) {
if (v == 'refresh') ctrl.loadConversations();
},
itemBuilder: (_) => [
const PopupMenuItem(
value: 'refresh',
child: Text('Refresh', style: TextStyle(color: AppTheme.textPrimary)),
),
],
),
],
);
}
Widget _buildConnecting() => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: AppTheme.primary),
const SizedBox(height: 16),
Text(
'Connecting to server...',
style: TextStyle(color: AppTheme.textSecondary),
),
],
),
);
Widget _buildError(ConversationsController ctrl) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.redAccent, size: 48),
const SizedBox(height: 12),
Text(
ctrl.errorMessage.value ?? 'Error',
style: const TextStyle(color: AppTheme.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: ctrl.loadConversations,
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary),
child: const Text('Retry'),
),
],
),
);
Widget _buildEmpty() => const Center(
child: Text(
'No conversations found',
style: TextStyle(color: AppTheme.textSecondary),
),
);
Widget _buildList(ConversationsController ctrl) {
return RefreshIndicator(
color: AppTheme.primary,
backgroundColor: AppTheme.surface,
onRefresh: ctrl.loadConversations,
child: ListView.builder(
itemCount: ctrl.conversations.length,
itemBuilder: (_, i) {
final chat = ctrl.conversations[i];
return ConversationTile(
conversation: chat,
onTap: () => _openChat(chat),
);
},
),
);
}
void _openChat(ConversationModel chat) {
Get.to(
() => ChatScreen(conversation: chat),
transition: Transition.rightToLeft,
);
}
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../services/whatsapp_service.dart';
import '../theme/app_theme.dart';
class QrView extends StatelessWidget {
const QrView({super.key});
@override
Widget build(BuildContext context) {
final svc = Get.find<WhatsAppService>();
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.qr_code_scanner,
color: AppTheme.primary, size: 64),
const SizedBox(height: 16),
const Text(
'Link with your phone',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. Open WhatsApp on your phone',
style:
TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
SizedBox(height: 4),
Text(
'2. Tap Menu (⋮ or ⚙️) → Linked Devices',
style:
TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
SizedBox(height: 4),
Text(
'3. Tap "Link a Device" and scan this QR code',
style:
TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
],
),
),
const SizedBox(height: 24),
Obx(() {
final qr = svc.qrData.value;
if (qr == null) {
return const CircularProgressIndicator(color: AppTheme.primary);
}
try {
final base64Image = qr.contains(',') ? qr.split(',')[1] : qr;
final bytes = base64Decode(base64Image);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Image.memory(
bytes,
width: 260,
height: 260,
fit: BoxFit.contain,
),
);
} catch (e) {
return Column(
children: [
const Icon(Icons.broken_image,
color: Colors.redAccent, size: 48),
const SizedBox(height: 8),
Text(
'Failed to render QR Code: $e',
style: const TextStyle(color: AppTheme.textSecondary),
),
],
);
}
}),
const SizedBox(height: 16),
Text(
'Waiting for QR Code from WhatsApp...',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'dart:convert';
import 'package:get/get.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../config/app_config.dart';
enum WsStatus { disconnected, connecting, connected, waReady }
class WhatsAppService extends GetxService {
// ── State ────────────────────────────────────────────────────────────────
final status = WsStatus.disconnected.obs;
final qrData = Rx<String?>(null);
final isWaReady = false.obs;
// ── Internal ─────────────────────────────────────────────────────────────
WebSocketChannel? _channel;
StreamSubscription? _sub;
int _reconnectCount = 0;
Timer? _reconnectTimer;
int _requestCounter = 0;
// Pending requests: requestId → Completer
final Map<String, Completer<Map<String, dynamic>>> _pending = {};
// Event streams for push events (new messages, ack updates)
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get events => _eventController.stream;
// ── Lifecycle ────────────────────────────────────────────────────────────
@override
void onInit() {
super.onInit();
connect();
}
@override
void onClose() {
_sub?.cancel();
_reconnectTimer?.cancel();
_channel?.sink.close();
_eventController.close();
super.onClose();
}
// ── Connection ───────────────────────────────────────────────────────────
void connect() {
if (status.value == WsStatus.connecting ||
status.value == WsStatus.connected ||
status.value == WsStatus.waReady) return;
status.value = WsStatus.connecting;
_reconnectTimer?.cancel();
try {
_channel = WebSocketChannel.connect(Uri.parse(AppConfig.wsUrl));
_sub?.cancel();
_sub = _channel!.stream.listen(
_onData,
onError: _onError,
onDone: _onDone,
);
status.value = WsStatus.connected;
_reconnectCount = 0;
// Request initial status check
ping();
} catch (e) {
_scheduleReconnect();
}
}
void _onData(dynamic raw) {
Map<String, dynamic> data;
try {
data = jsonDecode(raw as String);
} catch (_) {
return;
}
final type = data['type'] as String?;
final requestId = data['requestId'] as String?;
// Resolve pending request
if (requestId != null && _pending.containsKey(requestId)) {
_pending.remove(requestId)!.complete(data);
return;
}
// Push events
switch (type) {
case 'qr':
qrData.value = data['qr'];
isWaReady.value = false;
if (status.value == WsStatus.waReady) {
status.value = WsStatus.connected;
}
break;
case 'authenticated':
qrData.value = null;
break;
case 'ready':
isWaReady.value = true;
status.value = WsStatus.waReady;
qrData.value = null;
break;
case 'disconnected':
isWaReady.value = false;
status.value = WsStatus.connected;
break;
case 'status':
if (data['ready'] == true) {
isWaReady.value = true;
status.value = WsStatus.waReady;
qrData.value = null;
} else {
isWaReady.value = false;
if (status.value == WsStatus.waReady) {
status.value = WsStatus.connected;
}
}
break;
}
// Broadcast all events to listeners
_eventController.add(data);
}
void _onError(Object err) {
_handleDisconnect();
}
void _onDone() {
_handleDisconnect();
}
void _handleDisconnect() {
status.value = WsStatus.disconnected;
isWaReady.value = false;
// Reject all pending requests with error
final pendingKeys = List.from(_pending.keys);
for (final key in pendingKeys) {
_pending.remove(key)?.completeError(Exception('Connection lost'));
}
_scheduleReconnect();
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
if (_reconnectCount >= AppConfig.maxReconnectAttempts) {
print('[WS] Max reconnect attempts reached');
return;
}
_reconnectCount++;
_reconnectTimer = Timer(AppConfig.reconnectDelay, connect);
}
// ── Request/Response ─────────────────────────────────────────────────────
Future<Map<String, dynamic>> _request(Map<String, dynamic> payload) {
final id = (_requestCounter++).toString();
payload['requestId'] = id;
final completer = Completer<Map<String, dynamic>>();
if (status.value == WsStatus.disconnected) {
completer.completeError(Exception('WebSocket is disconnected'));
return completer.future;
}
_pending[id] = completer;
try {
_channel?.sink.add(jsonEncode(payload));
} catch (e) {
_pending.remove(id);
completer.completeError(e);
return completer.future;
}
// Timeout after 15s
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
_pending.remove(id);
throw TimeoutException('Request timed out: ${payload['type']}');
},
);
}
// ── Public API ───────────────────────────────────────────────────────────
Future<Map<String, dynamic>> getConversations({
int limit = 50,
int offset = 0,
}) => _request({ 'type': 'get_conversations', 'limit': limit, 'offset': offset });
Future<Map<String, dynamic>> getMessages(String chatId, {int limit = 50}) =>
_request({ 'type': 'get_messages', 'chatId': chatId, 'limit': limit });
Future<Map<String, dynamic>> sendMessage(String chatId, String text) =>
_request({ 'type': 'send_message', 'chatId': chatId, 'text': text });
Future<Map<String, dynamic>> markRead(String chatId) =>
_request({ 'type': 'mark_read', 'chatId': chatId });
Future<Map<String, dynamic>> searchConversations(String query) =>
_request({ 'type': 'search_conversations', 'query': query });
Future<Map<String, dynamic>> ping() =>
_request({ 'type': 'ping' });
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class AppTheme {
// Dark WhatsApp Palette
static const Color background = Color(0xff111b21);
static const Color surface = Color(0xff1f2c34);
static const Color surfaceLight = Color(0xff2a3942);
static const Color primary = Color(0xff00a884);
static const Color primaryDark = Color(0xff005c4b);
static const Color outgoingMsg = Color(0xff005c4b);
static const Color incomingMsg = Color(0xff1f2c34);
static const Color textPrimary = Color(0xffe9edef);
static const Color textSecondary = Color(0xff8696a0);
static const Color iconColor = Color(0xff8696a0);
static ThemeData get dark {
return ThemeData.dark().copyWith(
scaffoldBackgroundColor: background,
primaryColor: primary,
colorScheme: const ColorScheme.dark(
primary: primary,
background: background,
surface: surface,
),
appBarTheme: const AppBarTheme(
backgroundColor: surface,
elevation: 0,
iconTheme: IconThemeData(color: iconColor),
titleTextStyle: TextStyle(
color: textPrimary,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primary,
selectionColor: primaryDark,
selectionHandleColor: primary,
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/conversation_model.dart';
import '../theme/app_theme.dart';
class ConversationTile extends StatelessWidget {
final ConversationModel conversation;
final VoidCallback onTap;
const ConversationTile({
super.key,
required this.conversation,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final lastMsg = conversation.lastMessage;
final hasUnread = conversation.unreadCount > 0;
return ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: _buildAvatar(),
title: Row(
children: [
Expanded(
child: Text(
conversation.name,
style: const TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
_formatTime(conversation.timestamp),
style: TextStyle(
color: hasUnread ? AppTheme.primary : AppTheme.textSecondary,
fontSize: 12,
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
if (lastMsg != null && lastMsg.fromMe) ...[
const Icon(Icons.done_all, size: 16, color: AppTheme.primary), // Or proper ACK double tick
const SizedBox(width: 4),
],
Expanded(
child: Text(
_getSubtitleText(lastMsg),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (conversation.isMuted) ...[
const SizedBox(width: 8),
const Icon(Icons.volume_off, size: 16, color: AppTheme.textSecondary),
],
if (conversation.pinned) ...[
const SizedBox(width: 8),
const Icon(Icons.push_pin, size: 16, color: AppTheme.textSecondary),
],
if (hasUnread) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
child: Text(
conversation.unreadCount.toString(),
style: const TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
);
}
Widget _buildAvatar() {
if (conversation.avatar != null) {
return CircleAvatar(
radius: 26,
backgroundImage: NetworkImage(conversation.avatar!),
backgroundColor: AppTheme.surfaceLight,
);
}
return CircleAvatar(
radius: 26,
backgroundColor: AppTheme.primaryDark,
child: Text(
conversation.name.isNotEmpty ? conversation.name[0].toUpperCase() : '?',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
}
String _getSubtitleText(LastMessageModel? lastMsg) {
if (lastMsg == null) return '';
if (lastMsg.hasMedia) {
return '📷 Photo'; // or other media indicator
}
return lastMsg.body;
}
String _formatTime(int timestamp) {
if (timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final msgDate = DateTime(dt.year, dt.month, dt.day);
if (msgDate == today) {
return DateFormat('hh:mm a').format(dt);
} else if (msgDate == yesterday) {
return 'Yesterday';
} else if (now.difference(dt).inDays < 7) {
return DateFormat('EEEE').format(dt); // e.g. "Monday"
} else {
return DateFormat('MM/dd/yy').format(dt);
}
}
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/message_model.dart';
import '../theme/app_theme.dart';
class MessageBubble extends StatelessWidget {
final MessageModel message;
const MessageBubble({super.key, required this.message});
@override
Widget build(BuildContext context) {
final isMe = message.fromMe;
final align = isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start;
final bg = isMe ? AppTheme.outgoingMsg : AppTheme.incomingMsg;
final radius = isMe
? const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(0),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
)
: const BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
);
return Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column(
crossAxisAlignment: align,
children: [
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Show sender name in group chats if not from me
if (!isMe && message.author != null) ...[
Text(
message.author!,
style: const TextStyle(
color: AppTheme.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
],
// Media placeholder if it is media
if (message.hasMedia) ...[
_buildMediaPlaceholder(),
const SizedBox(height: 6),
],
// Message text & time row
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Text(
message.body,
style: const TextStyle(
color: AppTheme.textPrimary,
fontSize: 15,
),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 10,
),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildAckIcon(message.ack),
],
],
),
),
],
),
],
),
),
],
),
);
}
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
return const Icon(Icons.access_time, size: 13, color: AppTheme.textSecondary);
case 2: // Sent
return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary);
case 3: // Delivered
return const Icon(Icons.done_all, size: 15, color: AppTheme.textSecondary);
case 4: // Read
return const Icon(Icons.done_all, size: 15, color: Colors.blue);
default:
return const SizedBox.shrink();
}
}
String _formatTime(int timestamp) {
if (timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return DateFormat('h:mm a').format(dt);
}
}

650
whatsapp_app/pubspec.lock Normal file
View File

@@ -0,0 +1,650 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
get:
dependency: "direct main"
description:
name: get
sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a"
url: "https://pub.dev"
source: hosted
version: "4.7.3"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
url: "https://pub.dev"
source: hosted
version: "2.4.2+1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
timeago:
dependency: "direct main"
description:
name: timeago
sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e
url: "https://pub.dev"
source: hosted
version: "3.7.1"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
url: "https://pub.dev"
source: hosted
version: "2.4.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"

27
whatsapp_app/pubspec.yaml Normal file
View File

@@ -0,0 +1,27 @@
name: whatsapp_app
description: A complete WhatsApp Mirror App built with Flutter and GetX.
version: 1.0.0+1
publish_to: 'none'
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
web_socket_channel: ^2.4.0
get: ^4.6.6
cached_network_image: ^3.3.1
intl: ^0.19.0
timeago: ^3.6.1
shared_preferences: ^2.2.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View File

@@ -0,0 +1,23 @@
{
"name": "whatsapp_bridge",
"version": "1.0.0",
"description": "WhatsApp Standalone WebSockets Bridge Server",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [
"whatsapp",
"bridge",
"websockets"
],
"author": "Antigravity Dev Team",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"puppeteer": "^21.0.0",
"qrcode": "^1.5.3",
"whatsapp-web.js": "^1.26.0",
"ws": "^8.16.0"
}
}

394
whatsapp_bridge/server.js Normal file
View File

@@ -0,0 +1,394 @@
/**
* WhatsApp WebSocket Bridge Server (Node.js)
* Uses whatsapp-web.js to connect to WhatsApp via Puppeteer
* Exposes a WebSocket API and HTTP health endpoint on Port 3025
*/
const { Client, LocalAuth } = require('whatsapp-web.js');
const qrcode = require('qrcode');
const { WebSocketServer, WebSocket } = require('ws');
const express = require('express');
const http = require('http');
// ─── Config ────────────────────────────────────────────────────────────────
const PORT = 3025;
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// ─── State ─────────────────────────────────────────────────────────────────
let waClient = null;
let clientReady = false;
let qrCodeCache = null;
const connectedClients = new Set();
// ─── Error Handling (Never Crash) ──────────────────────────────────────────
process.on('uncaughtException', (err) => {
console.error('[CRITICAL] Uncaught Exception:', err);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[CRITICAL] Unhandled Rejection at:', promise, 'reason:', reason);
});
// ─── WebSocket: Broadcast to all Flutter clients ───────────────────────────
function broadcast(payload) {
const data = JSON.stringify(payload);
connectedClients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(data);
} catch (err) {
console.error('[WS] Broadcast send error:', err.message);
}
}
});
}
function sendTo(ws, payload) {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(payload));
} catch (err) {
console.error('[WS] Send error:', err.message);
}
}
}
// ─── Format conversation object for Flutter ────────────────────────────────
async function formatChat(chat) {
let avatar = null;
try {
const contact = await chat.getContact();
const pic = await contact.getProfilePicUrl();
if (pic) avatar = pic;
} catch (_) {}
// Last Message formatting
let lastMessageFormatted = null;
if (chat.lastMessage) {
lastMessageFormatted = {
body: chat.lastMessage.body || '',
timestamp: chat.lastMessage.timestamp || Math.floor(Date.now() / 1000),
fromMe: chat.lastMessage.fromMe || false,
hasMedia: chat.lastMessage.hasMedia || false
};
}
return {
id: chat.id._serialized,
name: chat.name || 'Unknown',
isGroup: chat.isGroup || false,
unreadCount: chat.unreadCount || 0,
avatar: avatar,
lastMessage: lastMessageFormatted,
timestamp: chat.timestamp || Math.floor(Date.now() / 1000),
pinned: chat.pinned || false,
isMuted: chat.isMuted || false
};
}
// ─── Format message object for Flutter ─────────────────────────────────────
function formatMessage(msg) {
// Map internal ack values if needed, otherwise fallback
// ack: 0=error/none 1=pending 2=sent 3=delivered 4=read
let ack = msg.ack || 0;
if (ack < 0) ack = 0;
// Restrict types to allowed values: "chat"|"image"|"video"|"audio"|"document"|"sticker"
let type = "chat";
if (["chat", "image", "video", "audio", "document", "sticker"].includes(msg.type)) {
type = msg.type;
} else if (msg.type === "ptt") {
type = "audio";
}
return {
id: msg.id._serialized,
body: msg.body || '',
fromMe: msg.fromMe || false,
timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
type: type,
hasMedia: msg.hasMedia || false,
isForwarded: msg.isForwarded || false,
author: msg.author || null,
ack: ack
};
}
// ─── Initialize WhatsApp Client ─────────────────────────────────────────────
function initWhatsApp() {
console.log('[WA] Initializing WhatsApp client using LocalAuth...');
clientReady = false;
waClient = new Client({
authStrategy: new LocalAuth({ clientId: 'whatsapp-bridge' }),
puppeteer: {
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--single-process',
'--disable-gpu',
],
},
});
// QR Code received -> send to all WS clients
waClient.on('qr', async (qr) => {
console.log('[WA] QR Code received');
try {
const qrDataUrl = await qrcode.toDataURL(qr);
qrCodeCache = qrDataUrl;
broadcast({ type: 'qr', qr: qrDataUrl });
} catch (err) {
console.error('[WA] QR generation error:', err);
}
});
// Authenticated
waClient.on('authenticated', () => {
console.log('[WA] Authenticated successfully');
qrCodeCache = null;
broadcast({ type: 'authenticated' });
});
// Ready
waClient.on('ready', () => {
console.log('[WA] WhatsApp Client Ready');
clientReady = true;
qrCodeCache = null;
broadcast({ type: 'ready' });
broadcast({ type: 'status', ready: true });
});
// New message received
waClient.on('message', async (msg) => {
console.log(`[WA] New message received from: ${msg.from}`);
try {
const formatted = formatMessage(msg);
broadcast({ type: 'new_message', chatId: msg.from, data: formatted });
} catch (err) {
console.error('[WA] Error formatting new message event:', err.message);
}
});
// Message ACK update
waClient.on('message_ack', (msg, ack) => {
try {
broadcast({
type: 'message_ack',
messageId: msg.id._serialized,
chatId: msg.to || msg.from,
ack: ack
});
} catch (err) {
console.error('[WA] Error sending message_ack event:', err.message);
}
});
// Disconnected
waClient.on('disconnected', (reason) => {
console.warn('[WA] Disconnected! Reason:', reason);
clientReady = false;
broadcast({ type: 'disconnected', reason });
broadcast({ type: 'status', ready: false });
// Clean up client resources
try {
waClient.destroy();
} catch (_) {}
// Auto-reconnect after 5 seconds
console.log('[WA] Reconnecting in 5 seconds...');
setTimeout(initWhatsApp, 5000);
});
waClient.initialize().catch((err) => {
console.error('[WA] Client initialization failed:', err);
console.log('[WA] Retrying initialization in 5 seconds...');
setTimeout(initWhatsApp, 5000);
});
}
// ─── Handle WebSocket messages from Flutter ────────────────────────────────
async function handleMessage(ws, raw) {
let payload;
try {
payload = JSON.parse(raw);
} catch (err) {
return sendTo(ws, { type: 'error', message: 'Invalid JSON payload' });
}
const { type, requestId } = payload;
if (!requestId) {
return sendTo(ws, { type: 'error', message: 'Missing requestId' });
}
const respond = (data) => sendTo(ws, { ...data, requestId });
// Handle type specific requests
try {
switch (type) {
// ── Ping ───────────────────────────────────────────────────────────
case 'ping':
return respond({ type: 'pong', ready: clientReady });
// ── Conversations ──────────────────────────────────────────────────
case 'get_conversations': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const chats = await waClient.getChats();
const limit = parseInt(payload.limit) || 50;
const offset = parseInt(payload.offset) || 0;
const slice = chats.slice(offset, offset + limit);
const formatted = await Promise.all(slice.map(formatChat));
return respond({
type: 'conversations',
data: formatted,
total: chats.length,
requestId
});
}
// ── Messages ───────────────────────────────────────────────────────
case 'get_messages': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const { chatId, limit } = payload;
if (!chatId) {
return respond({ type: 'error', message: 'chatId is required' });
}
const chat = await waClient.getChatById(chatId);
const fetchLimit = parseInt(limit) || 50;
const messages = await chat.fetchMessages({ limit: fetchLimit });
const formatted = messages.map(formatMessage);
return respond({
type: 'messages',
chatId: chatId,
data: formatted,
requestId
});
}
// ── Send Message ───────────────────────────────────────────────────
case 'send_message': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const { chatId, text } = payload;
if (!chatId || !text) {
return respond({ type: 'error', message: 'chatId and text are required' });
}
const sentMsg = await waClient.sendMessage(chatId, text);
return respond({
type: 'message_sent',
chatId: chatId,
data: formatMessage(sentMsg),
requestId
});
}
// ── Mark as Read ───────────────────────────────────────────────────
case 'mark_read': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const { chatId } = payload;
if (!chatId) {
return respond({ type: 'error', message: 'chatId is required' });
}
const chat = await waClient.getChatById(chatId);
await chat.sendSeen();
return respond({
type: 'marked_read',
chatId: chatId,
requestId
});
}
// ── Search Conversations ───────────────────────────────────────────
case 'search_conversations': {
if (!clientReady) {
return respond({ type: 'error', message: 'WhatsApp is not ready' });
}
const query = (payload.query || '').toLowerCase();
const chats = await waClient.getChats();
const filtered = chats.filter((c) =>
(c.name || '').toLowerCase().includes(query)
);
const formatted = await Promise.all(filtered.slice(0, 50).map(formatChat));
return respond({
type: 'conversations',
data: formatted,
search: true,
requestId
});
}
default:
return respond({ type: 'error', message: `Unknown request type: ${type}` });
}
} catch (err) {
console.error(`[WS] Error processing request ${type}:`, err);
return respond({ type: 'error', message: err.message || 'Server error' });
}
}
// ─── WebSocket Connection Handler ──────────────────────────────────────────
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
console.log(`[WS] New client connected from IP: ${ip}`);
connectedClients.add(ws);
// Send status immediately on connection
sendTo(ws, { type: 'status', ready: clientReady });
// If a QR code is active and client is not ready, push it immediately
if (!clientReady && qrCodeCache) {
sendTo(ws, { type: 'qr', qr: qrCodeCache });
}
ws.on('message', (data) => {
try {
handleMessage(ws, data.toString());
} catch (err) {
console.error('[WS] Error handling incoming data:', err.message);
}
});
ws.on('close', () => {
console.log(`[WS] Client disconnected: ${ip}`);
connectedClients.delete(ws);
});
ws.on('error', (err) => {
console.error(`[WS] Error on client connection ${ip}:`, err.message);
connectedClients.delete(ws);
});
});
// ─── HTTP Health Endpoint ──────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
waReady: clientReady,
clients: connectedClients.size,
port: PORT
});
});
// ─── Start HTTP + WebSocket Server ─────────────────────────────────────────
server.listen(PORT, () => {
console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`);
initWhatsApp();
});