Initial commit with Flutter and Node.js code
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user