Initial commit with Flutter and Node.js code

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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