commit a60a173b51405ed72e504bfd8bc6e12d24f0d0c8 Author: Hamza-Ayed Date: Mon May 18 14:04:39 2026 +0300 Initial commit with Flutter and Node.js code diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 0000000..c6438d2 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,3 @@ +[safe] + directory = /Users/hamzaaleghwairyeen/development/flutter + directory = /Users/hamzaaleghwairyeen/flutter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0e2d6c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c186e2f --- /dev/null +++ b/README.md @@ -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. diff --git a/push_to_server.sh b/push_to_server.sh new file mode 100755 index 0000000..0dcd106 --- /dev/null +++ b/push_to_server.sh @@ -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." diff --git a/whatsapp_app/lib/config/app_config.dart b/whatsapp_app/lib/config/app_config.dart new file mode 100644 index 0000000..fa5aab1 --- /dev/null +++ b/whatsapp_app/lib/config/app_config.dart @@ -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); +} diff --git a/whatsapp_app/lib/controllers/chat_controller.dart b/whatsapp_app/lib/controllers/chat_controller.dart new file mode 100644 index 0000000..c98e157 --- /dev/null +++ b/whatsapp_app/lib/controllers/chat_controller.dart @@ -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(); + + final messages = [].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 loadMessages() async { + isLoading.value = true; + try { + final res = await _svc.getMessages(conversation.id); + if (res['type'] == 'messages') { + final List data = res['data'] ?? []; + final fetched = data.map((m) => MessageModel.fromJson(m as Map)).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 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); + 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 markAsRead() async { + try { + await _svc.markRead(conversation.id); + } catch (e) { + print('[MARK READ ERROR] $e'); + } + } + + // ── Push Event Handler ─────────────────────────────────────────────────── + void _onPushEvent(Map 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?; + 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 get groupedMessages { + final list = []; + 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); + } + } +} diff --git a/whatsapp_app/lib/controllers/conversations_controller.dart b/whatsapp_app/lib/controllers/conversations_controller.dart new file mode 100644 index 0000000..59db09e --- /dev/null +++ b/whatsapp_app/lib/controllers/conversations_controller.dart @@ -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(); + + final conversations = [].obs; + final isLoading = false.obs; + final errorMessage = Rx(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 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 data = res['data'] ?? []; + conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map))); + } 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 data = res['data'] ?? []; + conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map))); + } + } catch (e) { + print('[SEARCH ERROR] $e'); + } finally { + isLoading.value = false; + } + }); + } + + // ── Handle Incoming Socket Push Events ────────────────────────────────── + void _onPushEvent(Map 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?; + 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 loadConversationsSilently() async { + if (!_svc.isWaReady.value) return; + try { + final res = await _svc.getConversations(); + if (res['type'] == 'conversations') { + final List data = res['data'] ?? []; + conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map))); + } + } catch (_) {} + } +} diff --git a/whatsapp_app/lib/main.dart b/whatsapp_app/lib/main.dart new file mode 100644 index 0000000..7bfe0a6 --- /dev/null +++ b/whatsapp_app/lib/main.dart @@ -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, + ); + } +} diff --git a/whatsapp_app/lib/models/conversation_model.dart b/whatsapp_app/lib/models/conversation_model.dart new file mode 100644 index 0000000..557f9a4 --- /dev/null +++ b/whatsapp_app/lib/models/conversation_model.dart @@ -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 json) { + return LastMessageModel( + body: json['body'] ?? '', + timestamp: json['timestamp'] ?? 0, + fromMe: json['fromMe'] ?? false, + hasMedia: json['hasMedia'] ?? false, + ); + } + + Map 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 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 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, + ); + } +} diff --git a/whatsapp_app/lib/models/message_model.dart b/whatsapp_app/lib/models/message_model.dart new file mode 100644 index 0000000..7f608ae --- /dev/null +++ b/whatsapp_app/lib/models/message_model.dart @@ -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 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 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, + ); + } +} diff --git a/whatsapp_app/lib/screens/chat_screen.dart b/whatsapp_app/lib/screens/chat_screen.dart new file mode 100644 index 0000000..b000c4a --- /dev/null +++ b/whatsapp_app/lib/screens/chat_screen.dart @@ -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, + ), + ), + ); + } +} diff --git a/whatsapp_app/lib/screens/conversations_screen.dart b/whatsapp_app/lib/screens/conversations_screen.dart new file mode 100644 index 0000000..9c9b1af --- /dev/null +++ b/whatsapp_app/lib/screens/conversations_screen.dart @@ -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(); + 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( + 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, + ); + } +} diff --git a/whatsapp_app/lib/screens/qr_screen.dart b/whatsapp_app/lib/screens/qr_screen.dart new file mode 100644 index 0000000..046445f --- /dev/null +++ b/whatsapp_app/lib/screens/qr_screen.dart @@ -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(); + + 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), + ), + ], + ), + ), + ); + } +} diff --git a/whatsapp_app/lib/services/whatsapp_service.dart b/whatsapp_app/lib/services/whatsapp_service.dart new file mode 100644 index 0000000..4407fb3 --- /dev/null +++ b/whatsapp_app/lib/services/whatsapp_service.dart @@ -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(null); + final isWaReady = false.obs; + + // ── Internal ───────────────────────────────────────────────────────────── + WebSocketChannel? _channel; + StreamSubscription? _sub; + int _reconnectCount = 0; + Timer? _reconnectTimer; + int _requestCounter = 0; + + // Pending requests: requestId → Completer + final Map>> _pending = {}; + + // Event streams for push events (new messages, ack updates) + final _eventController = StreamController>.broadcast(); + Stream> 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 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> _request(Map payload) { + final id = (_requestCounter++).toString(); + payload['requestId'] = id; + final completer = Completer>(); + + 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> getConversations({ + int limit = 50, + int offset = 0, + }) => _request({ 'type': 'get_conversations', 'limit': limit, 'offset': offset }); + + Future> getMessages(String chatId, {int limit = 50}) => + _request({ 'type': 'get_messages', 'chatId': chatId, 'limit': limit }); + + Future> sendMessage(String chatId, String text) => + _request({ 'type': 'send_message', 'chatId': chatId, 'text': text }); + + Future> markRead(String chatId) => + _request({ 'type': 'mark_read', 'chatId': chatId }); + + Future> searchConversations(String query) => + _request({ 'type': 'search_conversations', 'query': query }); + + Future> ping() => + _request({ 'type': 'ping' }); +} diff --git a/whatsapp_app/lib/theme/app_theme.dart b/whatsapp_app/lib/theme/app_theme.dart new file mode 100644 index 0000000..71cd86a --- /dev/null +++ b/whatsapp_app/lib/theme/app_theme.dart @@ -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, + ), + ); + } +} diff --git a/whatsapp_app/lib/widgets/conversation_tile.dart b/whatsapp_app/lib/widgets/conversation_tile.dart new file mode 100644 index 0000000..ffefbbf --- /dev/null +++ b/whatsapp_app/lib/widgets/conversation_tile.dart @@ -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); + } + } +} diff --git a/whatsapp_app/lib/widgets/message_bubble.dart b/whatsapp_app/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..223ae59 --- /dev/null +++ b/whatsapp_app/lib/widgets/message_bubble.dart @@ -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); + } +} diff --git a/whatsapp_app/pubspec.lock b/whatsapp_app/pubspec.lock new file mode 100644 index 0000000..d1f1650 --- /dev/null +++ b/whatsapp_app/pubspec.lock @@ -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" diff --git a/whatsapp_app/pubspec.yaml b/whatsapp_app/pubspec.yaml new file mode 100644 index 0000000..0121293 --- /dev/null +++ b/whatsapp_app/pubspec.yaml @@ -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 diff --git a/whatsapp_bridge/package.json b/whatsapp_bridge/package.json new file mode 100644 index 0000000..7b0e674 --- /dev/null +++ b/whatsapp_bridge/package.json @@ -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" + } +} diff --git a/whatsapp_bridge/server.js b/whatsapp_bridge/server.js new file mode 100644 index 0000000..53d09c4 --- /dev/null +++ b/whatsapp_bridge/server.js @@ -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(); +});