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);
|
||||
}
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user