Initial commit with Flutter and Node.js code
This commit is contained in:
3
.gitconfig
Normal file
3
.gitconfig
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[safe]
|
||||||
|
directory = /Users/hamzaaleghwairyeen/development/flutter
|
||||||
|
directory = /Users/hamzaaleghwairyeen/flutter
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
307
README.md
Normal 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
36
push_to_server.sh
Executable 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."
|
||||||
8
whatsapp_app/lib/config/app_config.dart
Normal file
8
whatsapp_app/lib/config/app_config.dart
Normal 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);
|
||||||
|
}
|
||||||
185
whatsapp_app/lib/controllers/chat_controller.dart
Normal file
185
whatsapp_app/lib/controllers/chat_controller.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
whatsapp_app/lib/controllers/conversations_controller.dart
Normal file
165
whatsapp_app/lib/controllers/conversations_controller.dart
Normal 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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
whatsapp_app/lib/main.dart
Normal file
34
whatsapp_app/lib/main.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
whatsapp_app/lib/models/conversation_model.dart
Normal file
109
whatsapp_app/lib/models/conversation_model.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
whatsapp_app/lib/models/message_model.dart
Normal file
75
whatsapp_app/lib/models/message_model.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
whatsapp_app/lib/screens/chat_screen.dart
Normal file
219
whatsapp_app/lib/screens/chat_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
whatsapp_app/lib/screens/conversations_screen.dart
Normal file
163
whatsapp_app/lib/screens/conversations_screen.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
whatsapp_app/lib/screens/qr_screen.dart
Normal file
108
whatsapp_app/lib/screens/qr_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
whatsapp_app/lib/services/whatsapp_service.dart
Normal file
208
whatsapp_app/lib/services/whatsapp_service.dart
Normal 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' });
|
||||||
|
}
|
||||||
44
whatsapp_app/lib/theme/app_theme.dart
Normal file
44
whatsapp_app/lib/theme/app_theme.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
whatsapp_app/lib/widgets/conversation_tile.dart
Normal file
149
whatsapp_app/lib/widgets/conversation_tile.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
whatsapp_app/lib/widgets/message_bubble.dart
Normal file
174
whatsapp_app/lib/widgets/message_bubble.dart
Normal 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
650
whatsapp_app/pubspec.lock
Normal 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
27
whatsapp_app/pubspec.yaml
Normal 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
|
||||||
23
whatsapp_bridge/package.json
Normal file
23
whatsapp_bridge/package.json
Normal 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
394
whatsapp_bridge/server.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user