From 22f1bba6ac205305084993d2d458681b71ec9bd2 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 19 May 2026 23:27:14 +0300 Subject: [PATCH] Sync update: 2026-05-19 23:27:14 --- .../lib/controllers/chat_controller.dart | 14 +- .../controllers/conversations_controller.dart | 1 + whatsapp_app/lib/main.dart | 20 +- .../lib/models/conversation_model.dart | 4 + whatsapp_app/lib/screens/chat_screen.dart | 616 +++++++++++------- .../lib/screens/conversations_screen.dart | 216 +++--- whatsapp_app/lib/screens/qr_screen.dart | 43 +- whatsapp_app/lib/theme/app_theme.dart | 125 +++- .../lib/widgets/conversation_tile.dart | 275 +++++--- whatsapp_app/lib/widgets/message_bubble.dart | 366 +++++++---- whatsapp_bridge/server.js | 3 +- 11 files changed, 1090 insertions(+), 593 deletions(-) diff --git a/whatsapp_app/lib/controllers/chat_controller.dart b/whatsapp_app/lib/controllers/chat_controller.dart index 86d022f..96a340e 100644 --- a/whatsapp_app/lib/controllers/chat_controller.dart +++ b/whatsapp_app/lib/controllers/chat_controller.dart @@ -181,13 +181,23 @@ class ChatController extends GetxController { case 'message_ack': final messageId = event['messageId'] as String?; final chatId = event['chatId'] as String?; - final ack = event['ack'] as int?; + // ack can arrive as int or double from JSON — handle both + final rawAck = event['ack']; + final ack = rawAck is int + ? rawAck + : rawAck is double + ? rawAck.toInt() + : null; 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); + // Force a list rebuild so Obx re-renders the bubble + final updated = messages[index].copyWith(ack: ack); + final newList = List.from(messages); + newList[index] = updated; + messages.assignAll(newList); } } break; diff --git a/whatsapp_app/lib/controllers/conversations_controller.dart b/whatsapp_app/lib/controllers/conversations_controller.dart index 14de1bb..f939d78 100644 --- a/whatsapp_app/lib/controllers/conversations_controller.dart +++ b/whatsapp_app/lib/controllers/conversations_controller.dart @@ -166,6 +166,7 @@ class ConversationsController extends GetxController { timestamp: msgData['timestamp'] ?? 0, fromMe: msgData['fromMe'] ?? false, hasMedia: msgData['hasMedia'] ?? false, + ack: msgData['ack'] ?? 0, ); // Find existing conversation and update it diff --git a/whatsapp_app/lib/main.dart b/whatsapp_app/lib/main.dart index 8ee755e..252f84d 100644 --- a/whatsapp_app/lib/main.dart +++ b/whatsapp_app/lib/main.dart @@ -10,28 +10,23 @@ import 'theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - // Initialize Firebase (Requires flutterfire configure) + + // Initialize Firebase try { await Firebase.initializeApp(); } catch (e) { print('Firebase initialization error: $e'); } - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - )); - // Register services before app starts Get.put(ContactsService(), permanent: true); Get.put(WhatsAppService(), permanent: true); Get.put(FirebaseService(), permanent: true); - + // Initialize Contacts Service await Get.find().init(); Get.find().init(); - + runApp(const WhatsAppApp()); } @@ -41,9 +36,12 @@ class WhatsAppApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( - title: 'WhatsApp App', + title: 'WhatsApp', debugShowCheckedModeBanner: false, - theme: AppTheme.dark, + // Follow device theme — no forced dark/light + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.system, home: const ConversationsScreen(), defaultTransition: Transition.cupertino, ); diff --git a/whatsapp_app/lib/models/conversation_model.dart b/whatsapp_app/lib/models/conversation_model.dart index 557f9a4..1485991 100644 --- a/whatsapp_app/lib/models/conversation_model.dart +++ b/whatsapp_app/lib/models/conversation_model.dart @@ -3,12 +3,14 @@ class LastMessageModel { final int timestamp; final bool fromMe; final bool hasMedia; + final int ack; LastMessageModel({ required this.body, required this.timestamp, required this.fromMe, required this.hasMedia, + required this.ack, }); factory LastMessageModel.fromJson(Map json) { @@ -17,6 +19,7 @@ class LastMessageModel { timestamp: json['timestamp'] ?? 0, fromMe: json['fromMe'] ?? false, hasMedia: json['hasMedia'] ?? false, + ack: json['ack'] ?? 0, ); } @@ -26,6 +29,7 @@ class LastMessageModel { 'timestamp': timestamp, 'fromMe': fromMe, 'hasMedia': hasMedia, + 'ack': ack, }; } } diff --git a/whatsapp_app/lib/screens/chat_screen.dart b/whatsapp_app/lib/screens/chat_screen.dart index e08da71..434cb8e 100644 --- a/whatsapp_app/lib/screens/chat_screen.dart +++ b/whatsapp_app/lib/screens/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import '../controllers/chat_controller.dart'; @@ -19,84 +20,177 @@ class ChatScreen extends StatelessWidget { ChatController(conversation: conversation), tag: conversation.id, ); + final isDark = AppTheme.isDark(context); return Scaffold( - backgroundColor: AppTheme.background, - appBar: _buildAppBar(conversation), + backgroundColor: AppTheme.chatBackground(context), + appBar: _buildAppBar(context, conversation, ctrl), body: Column( children: [ - Expanded(child: _buildMessageList(ctrl)), - _buildInputBar(ctrl), + Expanded(child: _buildMessageList(context, ctrl)), + _buildInputBar(context, 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, + PreferredSizeWidget _buildAppBar( + BuildContext context, + ConversationModel chat, + ChatController ctrl, + ) { + final isDark = AppTheme.isDark(context); + return AppBar( + backgroundColor: AppTheme.surface(context), + leadingWidth: 36, + titleSpacing: 0, + title: InkWell( + onTap: () {}, // Future: open contact info + child: Row( + children: [ + _buildAppBarAvatar(context, chat), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + chat.name, + style: TextStyle( + color: isDark ? AppTheme.darkTextPrimary : Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + // Status line + _buildStatusLine(context, chat, ctrl), + ], ), - if (chat.isGroup) - const Text( - 'Group', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - ], + ), + ], + ), + ), + actions: [ + IconButton( + icon: Icon( + Icons.videocam_outlined, + color: isDark ? AppTheme.darkTextSecondary : Colors.white, ), + onPressed: null, + ), + IconButton( + icon: Icon( + Icons.call_outlined, + color: isDark ? AppTheme.darkTextSecondary : Colors.white, + ), + onPressed: null, + ), + IconButton( + icon: Icon( + Icons.more_vert, + color: isDark ? AppTheme.darkTextSecondary : Colors.white, + ), + onPressed: null, ), ], - ), - 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) { + Widget _buildStatusLine( + BuildContext context, + ConversationModel chat, + ChatController ctrl, + ) { + final isDark = AppTheme.isDark(context); + final color = isDark + ? AppTheme.darkTextSecondary + : Colors.white.withOpacity(0.85); + + if (chat.isGroup) { + return Obx(() { + final count = ctrl.messages.length; + return Text( + count > 0 ? 'tap for group info' : 'Group', + style: TextStyle(color: color, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }); + } + + // For 1:1 chats, show "online" placeholder or nothing + // (Real status would come from the bridge server) + return Text( + '', + style: TextStyle(color: color, fontSize: 12), + ); + } + + Widget _buildAppBarAvatar(BuildContext context, ConversationModel chat) { + final isDark = AppTheme.isDark(context); + final fallbackBg = isDark + ? const Color(0xff2a3942) + : Colors.white.withOpacity(0.25); + + if (chat.avatar != null && chat.avatar!.isNotEmpty) { + return CircleAvatar( + radius: 18, + backgroundColor: fallbackBg, + child: ClipOval( + child: CachedNetworkImage( + imageUrl: chat.avatar!, + width: 36, + height: 36, + fit: BoxFit.cover, + placeholder: (_, __) => _defaultAvatarIcon(chat, fallbackBg), + errorWidget: (_, __, ___) => _defaultAvatarIcon(chat, fallbackBg), + ), + ), + ); + } + + return CircleAvatar( + radius: 18, + backgroundColor: fallbackBg, + child: _defaultAvatarIcon(chat, fallbackBg), + ); + } + + Widget _defaultAvatarIcon(ConversationModel chat, Color bg) { + return Icon( + chat.isGroup ? Icons.group : Icons.person, + color: Colors.white, + size: 20, + ); + } + + Widget _buildMessageList(BuildContext context, ChatController ctrl) { return Obx(() { - if (ctrl.isLoading.value) { - return const Center( + if (ctrl.isLoading.value && ctrl.messages.isEmpty) { + return 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), + Icon( + Icons.chat_bubble_outline, + color: AppTheme.textSecondary(context).withOpacity(0.4), + size: 48, + ), const SizedBox(height: 12), Text( 'No messages yet', - style: TextStyle(color: AppTheme.textSecondary.withOpacity(0.8)), + style: TextStyle( + color: AppTheme.textSecondary(context).withOpacity(0.8)), ), ], ), @@ -105,198 +199,246 @@ class ChatScreen extends StatelessWidget { return ListView.builder( controller: ctrl.scrollCtrl, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), itemCount: items.length, itemBuilder: (_, i) { final item = items[i]; - if (item is String) return _buildDateSeparator(item); + if (item is String) return _buildDateSeparator(context, 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 _buildDateSeparator(BuildContext context, String label) { + final isDark = AppTheme.isDark(context); + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: isDark + ? const Color(0xff1d2b33) + : const Color(0xffd1f4cc), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + label, + style: TextStyle( + color: isDark + ? AppTheme.darkTextSecondary + : const Color(0xff54656f), + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ), - ), - ); + ); + } - Widget _buildInputBar(ChatController ctrl) => Container( - color: AppTheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: SafeArea( - child: Obx(() { - if (ctrl.isRecording.value) { + Widget _buildInputBar(BuildContext context, ChatController ctrl) { + final isDark = AppTheme.isDark(context); + final barBg = isDark ? AppTheme.darkBackground : AppTheme.lightBackground; + final inputBg = isDark ? AppTheme.darkSurfaceLight : AppTheme.lightSurfaceLight; + + return Container( + color: barBg, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: SafeArea( + child: Obx(() { + // ── Recording UI ───────────────────────────────────────────────── + if (ctrl.isRecording.value) { + return Row( + children: [ + const SizedBox(width: 12), + const Icon(Icons.fiber_manual_record, + color: Colors.red, size: 14), + const SizedBox(width: 6), + const Text( + 'Recording...', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 14), + ), + const SizedBox(width: 12), + Text( + '${(ctrl.recordDuration.value ~/ 60).toString().padLeft(2, '0')}:${(ctrl.recordDuration.value % 60).toString().padLeft(2, '0')}', + style: TextStyle( + color: AppTheme.textPrimary(context), + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()]), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.delete, + color: Colors.redAccent, size: 18), + label: const Text('Cancel', + style: TextStyle(color: Colors.redAccent)), + onPressed: ctrl.cancelRecording, + ), + const SizedBox(width: 8), + GestureDetector( + onTap: ctrl.stopAndSendRecording, + child: Container( + width: 44, + height: 44, + decoration: const BoxDecoration( + color: AppTheme.primary, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check, + color: Colors.white, size: 20), + ), + ), + const SizedBox(width: 8), + ], + ); + } + + // ── Normal Input ───────────────────────────────────────────────── return Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: 12), - const Icon(Icons.fiber_manual_record, color: Colors.red, size: 16), - const SizedBox(width: 8), - const Text( - 'Recording...', - style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 14), - ), - const SizedBox(width: 12), - Text( - '${(ctrl.recordDuration.value ~/ 60).toString().padLeft(2, '0')}:${(ctrl.recordDuration.value % 60).toString().padLeft(2, '0')}', - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14, fontFamily: 'monospace'), - ), - const Spacer(), - TextButton.icon( - icon: const Icon(Icons.delete, color: Colors.redAccent, size: 18), - label: const Text('Cancel', style: TextStyle(color: Colors.redAccent)), - onPressed: ctrl.cancelRecording, - ), - const SizedBox(width: 8), - GestureDetector( - onTap: ctrl.stopAndSendRecording, + // ── Text input field ───────────────────────────────────────── + Expanded( child: Container( - width: 44, - height: 44, + decoration: BoxDecoration( + color: inputBg, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Emoji button + IconButton( + icon: Icon(Icons.emoji_emotions_outlined, + color: AppTheme.textSecondary(context)), + onPressed: null, + padding: const EdgeInsets.only(bottom: 2), + ), + Expanded( + child: TextField( + controller: ctrl.inputCtrl, + style: TextStyle( + color: AppTheme.textPrimary(context), + fontSize: 15), + maxLines: 5, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: AppTheme.textSecondary(context)), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + ), + ), + ), + // Attachment button + IconButton( + icon: Icon(Icons.attach_file, + color: AppTheme.textSecondary(context)), + onPressed: () => _showAttachmentSheet(context, ctrl), + padding: const EdgeInsets.only(bottom: 2), + ), + // Camera button (only when no text) + Obx(() => ctrl.hasText.value + ? const SizedBox.shrink() + : IconButton( + icon: Icon(Icons.camera_alt_outlined, + color: AppTheme.textSecondary(context)), + onPressed: () => + _pickAndSendImage(ctrl, ImageSource.camera), + padding: const EdgeInsets.only(bottom: 2, right: 4), + )), + ], + ), + ), + ), + const SizedBox(width: 8), + + // ── Send / Mic button ───────────────────────────────────────── + GestureDetector( + onTap: () { + if (ctrl.hasText.value) { + ctrl.sendMessage(); + } else { + ctrl.startRecording(); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 48, + height: 48, decoration: const BoxDecoration( color: AppTheme.primary, shape: BoxShape.circle, ), - child: const Icon(Icons.check, color: Colors.white, size: 20), + child: ctrl.isSending.value + ? const Padding( + padding: EdgeInsets.all(13), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Obx(() => Icon( + ctrl.hasText.value + ? Icons.send_rounded + : Icons.mic_rounded, + color: Colors.white, + size: 22, + )), ), ), - const SizedBox(width: 8), ], ); - } + }), + ), + ); + } - return Row( - children: [ - // Attachment button - IconButton( - icon: const Icon(Icons.add, color: AppTheme.primary, size: 28), - onPressed: () => _showAttachmentSheet(ctrl), - ), - // 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), - // Dynamic Send / Mic Button - GestureDetector( - onTap: () { - if (ctrl.hasText.value) { - ctrl.sendMessage(); - } else { - ctrl.startRecording(); - } - }, - 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, - ), - ) - : Icon( - ctrl.hasText.value ? Icons.send : Icons.mic, - color: Colors.white, - size: 20, - ), - ), - ), - const SizedBox(width: 4), - ], - ); - }), - ), - ); - - void _showAttachmentSheet(ChatController ctrl) { + void _showAttachmentSheet(BuildContext context, ChatController ctrl) { + final isDark = AppTheme.isDark(context); Get.bottomSheet( Container( - decoration: const BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.only( + decoration: BoxDecoration( + color: isDark ? AppTheme.darkSurface : Colors.white, + borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), ), - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 40, + width: 36, height: 4, decoration: BoxDecoration( - color: AppTheme.textSecondary.withOpacity(0.3), + color: AppTheme.textSecondary(context).withOpacity(0.3), borderRadius: BorderRadius.circular(2), ), ), - const SizedBox(height: 24), - const Text( - 'Send Media Attachment', - style: TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 24), + const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildAttachmentItem( - icon: Icons.camera_alt, - color: Colors.green, - label: 'Camera', - onTap: () { - Get.back(); - _pickAndSendImage(ctrl, ImageSource.camera); - }, - ), - _buildAttachmentItem( - icon: Icons.photo_library, - color: Colors.purple, + context: context, + icon: Icons.photo_library_rounded, + color: const Color(0xff7c4dff), label: 'Gallery', onTap: () { Get.back(); @@ -304,23 +446,34 @@ class ChatScreen extends StatelessWidget { }, ), _buildAttachmentItem( - icon: Icons.mic, - color: Colors.orange, - label: 'Voice Note', + context: context, + icon: Icons.camera_alt_rounded, + color: const Color(0xffff4081), + label: 'Camera', onTap: () { Get.back(); - // 100% valid MP3 silent audio base64 snippet to prevent getAudioDuration errors - const base64Audio = 'SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAbAAqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQDkAAAAAAAAAGw9wrNaQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAAAAANIAAAAAExBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV/+MYxDsAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV/+MYxHYAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV'; - ctrl.sendMediaMessage( - base64Audio, - 'audio/mp3', - 'voice_note.mp3', - ); + _pickAndSendImage(ctrl, ImageSource.camera); + }, + ), + _buildAttachmentItem( + context: context, + icon: Icons.insert_drive_file_rounded, + color: const Color(0xff2196f3), + label: 'Document', + onTap: () => Get.back(), + ), + _buildAttachmentItem( + context: context, + icon: Icons.mic_rounded, + color: const Color(0xffff9800), + label: 'Audio', + onTap: () { + Get.back(); + ctrl.startRecording(); }, ), ], ), - const SizedBox(height: 16), ], ), ), @@ -339,7 +492,7 @@ class ChatScreen extends StatelessWidget { final bytes = await image.readAsBytes(); final base64String = base64Encode(bytes); - + String mimetype = 'image/jpeg'; if (image.path.toLowerCase().endsWith('.png')) { mimetype = 'image/png'; @@ -351,7 +504,6 @@ class ChatScreen extends StatelessWidget { base64String, mimetype, image.name, - caption: '📸 Photo sent via Mywhatsapp!', ); } catch (e) { Get.snackbar( @@ -364,48 +516,34 @@ class ChatScreen extends StatelessWidget { } Widget _buildAttachmentItem({ + required BuildContext context, required IconData icon, required Color color, required String label, required VoidCallback onTap, }) { + final isDark = AppTheme.isDark(context); return GestureDetector( onTap: onTap, child: Column( children: [ - CircleAvatar( - radius: 28, - backgroundColor: color.withOpacity(0.15), - child: Icon(icon, color: color, size: 28), + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 26), ), const SizedBox(height: 8), Text( label, - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 12), + style: TextStyle( + color: AppTheme.textPrimary(context), fontSize: 12), ), ], ), ); } - - Widget _avatar(ConversationModel chat, {double radius = 24}) { - if (chat.avatar != null) { - return CircleAvatar( - radius: radius, - backgroundImage: NetworkImage(chat.avatar!), - backgroundColor: AppTheme.surfaceLight, - ); - } - return CircleAvatar( - radius: radius, - backgroundColor: AppTheme.primaryDark, - child: Text( - chat.name.isNotEmpty ? chat.name[0].toUpperCase() : '?', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ); - } } diff --git a/whatsapp_app/lib/screens/conversations_screen.dart b/whatsapp_app/lib/screens/conversations_screen.dart index 9c9b1af..55f07b8 100644 --- a/whatsapp_app/lib/screens/conversations_screen.dart +++ b/whatsapp_app/lib/screens/conversations_screen.dart @@ -15,134 +15,202 @@ class ConversationsScreen extends StatelessWidget { Widget build(BuildContext context) { final svc = Get.find(); final ctrl = Get.put(ConversationsController()); + final isDark = AppTheme.isDark(context); return Scaffold( - backgroundColor: AppTheme.background, - appBar: _buildAppBar(ctrl), + backgroundColor: AppTheme.background(context), + appBar: _buildAppBar(context, ctrl), body: Obx(() { // Not connected if (svc.status.value == WsStatus.disconnected || svc.status.value == WsStatus.connecting) { - return _buildConnecting(); + return _buildConnecting(context); } // QR Code needed if (svc.qrData.value != null) { return const QrView(); } // Loading conversations - if (ctrl.isLoading.value) { - return const Center( + if (ctrl.isLoading.value && ctrl.conversations.isEmpty) { + return Center( child: CircularProgressIndicator(color: AppTheme.primary), ); } // Error - if (ctrl.errorMessage.value != null) { - return _buildError(ctrl); + if (ctrl.errorMessage.value != null && ctrl.conversations.isEmpty) { + return _buildError(context, ctrl); } // Empty if (ctrl.conversations.isEmpty) { - return _buildEmpty(); + return _buildEmpty(context); } // List - return _buildList(ctrl); + return _buildList(context, ctrl); }), ); } - AppBar _buildAppBar(ConversationsController ctrl) { + PreferredSizeWidget _buildAppBar( + BuildContext context, ConversationsController ctrl) { final searching = false.obs; + final isDark = AppTheme.isDark(context); return AppBar( - backgroundColor: AppTheme.surface, + backgroundColor: AppTheme.surface(context), + elevation: 0, title: Obx(() => searching.value ? TextField( autofocus: true, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( + style: TextStyle(color: isDark ? Colors.white : Colors.white), + cursorColor: Colors.white, + decoration: InputDecoration( hintText: 'Search...', border: InputBorder.none, - hintStyle: TextStyle(color: AppTheme.textSecondary), + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.7)), ), onChanged: ctrl.search, ) - : const Text('WhatsApp', style: TextStyle(color: AppTheme.textPrimary))), + : const Text( + 'WhatsApp', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + )), actions: [ Obx(() => IconButton( + icon: Icon( + searching.value ? Icons.close : Icons.search, + color: isDark ? AppTheme.darkTextSecondary : Colors.white, + ), + onPressed: () { + searching.value = !searching.value; + if (!searching.value) ctrl.loadConversations(); + }, + )), + IconButton( icon: Icon( - searching.value ? Icons.close : Icons.search, - color: AppTheme.iconColor, + Icons.more_vert, + color: isDark ? AppTheme.darkTextSecondary : Colors.white, + ), + onPressed: () => _showOptionsMenu(context, ctrl), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 1, + color: isDark + ? Colors.white.withOpacity(0.08) + : Colors.white.withOpacity(0.15), + ), + ), + ); + } + + void _showOptionsMenu( + BuildContext context, ConversationsController ctrl) { + showMenu( + context: context, + position: const RelativeRect.fromLTRB(1000, 56, 0, 0), + color: AppTheme.isDark(context) + ? AppTheme.darkSurface + : Colors.white, + items: [ + PopupMenuItem( + onTap: ctrl.loadConversations, + child: Text( + 'Refresh', + style: TextStyle(color: AppTheme.textPrimary(context)), ), - onPressed: () { - searching.value = !searching.value; - if (!searching.value) ctrl.loadConversations(); - }, - )), - PopupMenuButton( - icon: const Icon(Icons.more_vert, color: AppTheme.iconColor), - color: AppTheme.surface, - onSelected: (v) { - if (v == 'refresh') ctrl.loadConversations(); - }, - itemBuilder: (_) => [ - const PopupMenuItem( - value: 'refresh', - child: Text('Refresh', style: TextStyle(color: AppTheme.textPrimary)), - ), - ], ), ], ); } - Widget _buildConnecting() => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(color: AppTheme.primary), - const SizedBox(height: 16), - Text( - 'Connecting to server...', - style: TextStyle(color: AppTheme.textSecondary), + Widget _buildConnecting(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: AppTheme.primary), + const SizedBox(height: 16), + Text( + 'Connecting to server...', + style: + TextStyle(color: AppTheme.textSecondary(context)), + ), + ], ), - ], - ), - ); + ); - 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, + Widget _buildError( + BuildContext context, ConversationsController ctrl) => + Center( + child: Padding( + padding: const EdgeInsets.all(24), + 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: TextStyle( + color: AppTheme.textSecondary(context)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: ctrl.loadConversations, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary), + child: const Text('Retry', + style: TextStyle(color: Colors.white)), + ), + ], + ), ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: ctrl.loadConversations, - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), - child: const Text('Retry'), + ); + + Widget _buildEmpty(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, + size: 64, + color: + AppTheme.textSecondary(context).withOpacity(0.4)), + const SizedBox(height: 16), + Text( + 'No conversations yet', + style: TextStyle( + color: AppTheme.textSecondary(context), + fontSize: 16), + ), + ], ), - ], - ), - ); + ); - Widget _buildEmpty() => const Center( - child: Text( - 'No conversations found', - style: TextStyle(color: AppTheme.textSecondary), - ), - ); - - Widget _buildList(ConversationsController ctrl) { + Widget _buildList( + BuildContext context, ConversationsController ctrl) { return RefreshIndicator( color: AppTheme.primary, - backgroundColor: AppTheme.surface, + backgroundColor: AppTheme.isDark(context) + ? AppTheme.darkSurface + : Colors.white, onRefresh: ctrl.loadConversations, - child: ListView.builder( + child: ListView.separated( itemCount: ctrl.conversations.length, + separatorBuilder: (_, __) => Divider( + height: 1, + thickness: 0.5, + indent: 76, + color: AppTheme.isDark(context) + ? Colors.white.withOpacity(0.06) + : Colors.black.withOpacity(0.08), + ), itemBuilder: (_, i) { final chat = ctrl.conversations[i]; return ConversationTile( diff --git a/whatsapp_app/lib/screens/qr_screen.dart b/whatsapp_app/lib/screens/qr_screen.dart index 046445f..9c86296 100644 --- a/whatsapp_app/lib/screens/qr_screen.dart +++ b/whatsapp_app/lib/screens/qr_screen.dart @@ -20,40 +20,41 @@ class QrView extends StatelessWidget { const Icon(Icons.qr_code_scanner, color: AppTheme.primary, size: 64), const SizedBox(height: 16), - const Text( + Text( 'Link with your phone', style: TextStyle( - color: AppTheme.textPrimary, + color: AppTheme.textPrimary(context), fontSize: 22, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: AppTheme.surfaceLight, + color: AppTheme.surfaceLight(context), borderRadius: BorderRadius.circular(8), ), - child: const Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '1. Open WhatsApp on your phone', - style: - TextStyle(color: AppTheme.textSecondary, fontSize: 14), + style: TextStyle( + color: AppTheme.textSecondary(context), fontSize: 14), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( '2. Tap Menu (⋮ or ⚙️) → Linked Devices', - style: - TextStyle(color: AppTheme.textSecondary, fontSize: 14), + style: TextStyle( + color: AppTheme.textSecondary(context), fontSize: 14), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( '3. Tap "Link a Device" and scan this QR code', - style: - TextStyle(color: AppTheme.textSecondary, fontSize: 14), + style: TextStyle( + color: AppTheme.textSecondary(context), fontSize: 14), ), ], ), @@ -66,13 +67,21 @@ class QrView extends StatelessWidget { } try { - final base64Image = qr.contains(',') ? qr.split(',')[1] : qr; + 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), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 12, + offset: const Offset(0, 4), + ) + ], ), child: Image.memory( bytes, @@ -89,7 +98,8 @@ class QrView extends StatelessWidget { const SizedBox(height: 8), Text( 'Failed to render QR Code: $e', - style: const TextStyle(color: AppTheme.textSecondary), + style: TextStyle( + color: AppTheme.textSecondary(context)), ), ], ); @@ -98,7 +108,8 @@ class QrView extends StatelessWidget { const SizedBox(height: 16), Text( 'Waiting for QR Code from WhatsApp...', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + style: TextStyle( + color: AppTheme.textSecondary(context), fontSize: 12), ), ], ), diff --git a/whatsapp_app/lib/theme/app_theme.dart b/whatsapp_app/lib/theme/app_theme.dart index 71cd86a..5f7a050 100644 --- a/whatsapp_app/lib/theme/app_theme.dart +++ b/whatsapp_app/lib/theme/app_theme.dart @@ -1,39 +1,56 @@ 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); + // ── WhatsApp Dark Palette ──────────────────────────────────────────────── + static const Color darkBackground = Color(0xff111b21); + static const Color darkSurface = Color(0xff1f2c34); + static const Color darkSurfaceLight = Color(0xff2a3942); + static const Color darkOutgoingMsg = Color(0xff005c4b); + static const Color darkIncomingMsg = Color(0xff1f2c34); + static const Color darkTextPrimary = Color(0xffe9edef); + static const Color darkTextSecondary= Color(0xff8696a0); + // ── WhatsApp Light Palette ─────────────────────────────────────────────── + static const Color lightBackground = Color(0xffffffff); + static const Color lightSurface = Color(0xff075e54); // WhatsApp green header + static const Color lightSurfaceLight = Color(0xfff0f2f5); + static const Color lightOutgoingMsg = Color(0xffd9fdd3); + static const Color lightIncomingMsg = Color(0xffffffff); + static const Color lightTextPrimary = Color(0xff111b21); + static const Color lightTextSecondary= Color(0xff667781); + static const Color lightChatBg = Color(0xffe5ddd5); // WhatsApp chat wallpaper bg + + // ── Shared Colors ──────────────────────────────────────────────────────── + static const Color primary = Color(0xff25d366); // WhatsApp green + static const Color primaryDark = Color(0xff128c7e); + static const Color teal = Color(0xff075e54); + static const Color blueTick = Color(0xff53bdeb); // WhatsApp blue double tick + static const Color greyTick = Color(0xff667781); + + // ── Dark Theme ─────────────────────────────────────────────────────────── static ThemeData get dark { - return ThemeData.dark().copyWith( - scaffoldBackgroundColor: background, - primaryColor: primary, + return ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: darkBackground, + primaryColor: teal, colorScheme: const ColorScheme.dark( primary: primary, - background: background, - surface: surface, + secondary: primaryDark, + surface: darkSurface, + background: darkBackground, ), appBarTheme: const AppBarTheme( - backgroundColor: surface, + backgroundColor: darkSurface, + foregroundColor: darkTextPrimary, elevation: 0, - iconTheme: IconThemeData(color: iconColor), + iconTheme: IconThemeData(color: darkTextSecondary), titleTextStyle: TextStyle( - color: textPrimary, + color: darkTextPrimary, fontSize: 20, fontWeight: FontWeight.bold, ), ), + dividerColor: darkSurfaceLight, textSelectionTheme: const TextSelectionThemeData( cursorColor: primary, selectionColor: primaryDark, @@ -41,4 +58,70 @@ class AppTheme { ), ); } + + // ── Light Theme ────────────────────────────────────────────────────────── + static ThemeData get light { + return ThemeData( + brightness: Brightness.light, + scaffoldBackgroundColor: lightBackground, + primaryColor: teal, + colorScheme: const ColorScheme.light( + primary: teal, + secondary: primary, + surface: lightSurface, + background: lightBackground, + ), + appBarTheme: const AppBarTheme( + backgroundColor: teal, + foregroundColor: Colors.white, + elevation: 0, + iconTheme: IconThemeData(color: Colors.white), + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + dividerColor: Color(0xffe0e0e0), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: teal, + selectionColor: primary, + selectionHandleColor: teal, + ), + ); + } + + // ── Context-aware helpers ───────────────────────────────────────────────── + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + static Color background(BuildContext context) => + isDark(context) ? darkBackground : lightBackground; + + static Color surface(BuildContext context) => + isDark(context) ? darkSurface : lightSurface; + + static Color surfaceLight(BuildContext context) => + isDark(context) ? darkSurfaceLight : lightSurfaceLight; + + static Color outgoingMsg(BuildContext context) => + isDark(context) ? darkOutgoingMsg : lightOutgoingMsg; + + static Color incomingMsg(BuildContext context) => + isDark(context) ? darkIncomingMsg : lightIncomingMsg; + + static Color chatBackground(BuildContext context) => + isDark(context) ? darkBackground : lightChatBg; + + static Color textPrimary(BuildContext context) => + isDark(context) ? darkTextPrimary : lightTextPrimary; + + static Color textSecondary(BuildContext context) => + isDark(context) ? darkTextSecondary : lightTextSecondary; + + static Color iconColor(BuildContext context) => + isDark(context) ? darkTextSecondary : Colors.white; + + static Color subtitleIconColor(BuildContext context) => + isDark(context) ? darkTextSecondary : lightTextSecondary; } diff --git a/whatsapp_app/lib/widgets/conversation_tile.dart b/whatsapp_app/lib/widgets/conversation_tile.dart index ffefbbf..6aecc18 100644 --- a/whatsapp_app/lib/widgets/conversation_tile.dart +++ b/whatsapp_app/lib/widgets/conversation_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; import '../models/conversation_model.dart'; import '../theme/app_theme.dart'; @@ -17,117 +18,217 @@ class ConversationTile extends StatelessWidget { Widget build(BuildContext context) { final lastMsg = conversation.lastMessage; final hasUnread = conversation.unreadCount > 0; + final isDark = AppTheme.isDark(context); - return ListTile( + return InkWell( 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: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 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), - ], + // ── Avatar ────────────────────────────────────────────────────── + _buildAvatar(context, conversation), + const SizedBox(width: 12), + + // ── Content ───────────────────────────────────────────────────── Expanded( - child: Text( - _getSubtitleText(lastMsg), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name row + time + Row( + children: [ + Expanded( + child: Text( + conversation.name, + style: TextStyle( + color: AppTheme.textPrimary(context), + fontSize: 16.5, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Text( + _formatTime(conversation.timestamp), + style: TextStyle( + color: hasUnread + ? AppTheme.primary + : AppTheme.textSecondary(context), + fontSize: 12, + fontWeight: + hasUnread ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + const SizedBox(height: 4), + + // Subtitle row: ack icon + preview + badges + Row( + children: [ + // ── ACK icon for sent messages ─────────────────────── + if (lastMsg != null && lastMsg.fromMe) ...[ + _buildAckIcon(context, lastMsg.ack), + const SizedBox(width: 3), + ], + + // ── Message preview ────────────────────────────────── + Expanded( + child: Text( + _getSubtitleText(context, lastMsg), + style: TextStyle( + color: AppTheme.textSecondary(context), + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // ── Trailing badges ────────────────────────────────── + if (conversation.isMuted) ...[ + const SizedBox(width: 4), + Icon(Icons.volume_off, + size: 15, color: AppTheme.textSecondary(context)), + ], + if (conversation.pinned) ...[ + const SizedBox(width: 4), + Icon(Icons.push_pin, + size: 15, color: AppTheme.textSecondary(context)), + ], + if (hasUnread) ...[ + const SizedBox(width: 6), + _buildUnreadBadge(conversation.unreadCount), + ], + ], + ), + ], ), ), - 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) { + // ── Avatar builder (cached network image + fallback initials) ───────────── + Widget _buildAvatar(BuildContext context, ConversationModel c) { + final isDark = AppTheme.isDark(context); + final fallbackBg = + isDark ? const Color(0xff2a3942) : const Color(0xff6b7c85); + + if (c.avatar != null && c.avatar!.isNotEmpty) { return CircleAvatar( - radius: 26, - backgroundImage: NetworkImage(conversation.avatar!), - backgroundColor: AppTheme.surfaceLight, + radius: 28, + backgroundColor: fallbackBg, + child: ClipOval( + child: CachedNetworkImage( + imageUrl: c.avatar!, + width: 56, + height: 56, + fit: BoxFit.cover, + placeholder: (_, __) => _initialsAvatar(c.name, fallbackBg), + errorWidget: (_, __, ___) => _initialsAvatar(c.name, fallbackBg), + ), + ), ); } + + // Group icon or person icon + if (c.isGroup) { + return CircleAvatar( + radius: 28, + backgroundColor: fallbackBg, + child: const Icon(Icons.group, color: Colors.white, size: 30), + ); + } + 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, - ), + radius: 28, + backgroundColor: fallbackBg, + child: _initialsAvatar(c.name, fallbackBg), + ); + } + + Widget _initialsAvatar(String name, Color bg) { + return Container( + width: 56, + height: 56, + color: bg, + alignment: Alignment.center, + child: Icon( + Icons.person, + color: Colors.white, + size: 30, ), ); } - String _getSubtitleText(LastMessageModel? lastMsg) { + // ── Unread badge ────────────────────────────────────────────────────────── + Widget _buildUnreadBadge(int count) { + return Container( + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: const BoxDecoration( + color: AppTheme.primary, + shape: BoxShape.circle, + ), + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 11.5, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ); + } + + // ── ACK (delivery status) icon ──────────────────────────────────────────── + // Real WhatsApp ACK levels from whatsapp-web.js: + // -1 = error → clock (pending/error) + // 0 = pending → clock + // 1 = sent → single grey tick + // 2 = received → double grey tick + // 3 = read/played→ double blue tick + Widget _buildAckIcon(BuildContext context, int ack) { + switch (ack) { + case -1: + case 0: + // Pending / clock + return Icon(Icons.access_time_rounded, + size: 14, color: AppTheme.textSecondary(context)); + case 1: + // Sent — single grey tick + return Icon(Icons.check_rounded, + size: 15, color: AppTheme.textSecondary(context)); + case 2: + // Delivered — double grey tick + return Icon(Icons.done_all_rounded, + size: 15, color: AppTheme.textSecondary(context)); + case 3: + // Read — double blue tick + return const Icon(Icons.done_all_rounded, + size: 15, color: AppTheme.blueTick); + default: + return const SizedBox.shrink(); + } + } + + // ── Subtitle text ───────────────────────────────────────────────────────── + String _getSubtitleText(BuildContext context, LastMessageModel? lastMsg) { if (lastMsg == null) return ''; if (lastMsg.hasMedia) { - return '📷 Photo'; // or other media indicator + return '📷 Photo'; } return lastMsg.body; } + // ── Time formatter ──────────────────────────────────────────────────────── String _formatTime(int timestamp) { if (timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); @@ -137,13 +238,13 @@ class ConversationTile extends StatelessWidget { final msgDate = DateTime(dt.year, dt.month, dt.day); if (msgDate == today) { - return DateFormat('hh:mm a').format(dt); + return DateFormat('h: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" + return DateFormat('EEEE').format(dt); } else { - return DateFormat('MM/dd/yy').format(dt); + return DateFormat('dd/MM/yy').format(dt); } } } diff --git a/whatsapp_app/lib/widgets/message_bubble.dart b/whatsapp_app/lib/widgets/message_bubble.dart index dec29bf..2b4917e 100644 --- a/whatsapp_app/lib/widgets/message_bubble.dart +++ b/whatsapp_app/lib/widgets/message_bubble.dart @@ -52,118 +52,171 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final isMe = message.fromMe; - final align = isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start; - final bg = isMe ? AppTheme.outgoingMsg : AppTheme.incomingMsg; + final isMe = message.fromMe; + final isDark = AppTheme.isDark(context); + + final bg = isMe + ? AppTheme.outgoingMsg(context) + : AppTheme.incomingMsg(context); + final radius = isMe ? const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(0), - bottomLeft: Radius.circular(12), + topLeft: Radius.circular(12), + topRight: Radius.circular(4), + bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ) : const BorderRadius.only( - topLeft: Radius.circular(0), - topRight: Radius.circular(12), - bottomLeft: Radius.circular(12), + topLeft: Radius.circular(4), + topRight: Radius.circular(12), + bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ); + // Incoming message shadow/border in light mode + final BoxDecoration decoration = BoxDecoration( + color: bg, + borderRadius: radius, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(isDark ? 0.2 : 0.08), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ); + return Container( - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: EdgeInsets.only( + top: 2, + bottom: 2, + left: isMe ? 60 : 8, + right: isMe ? 8 : 60, + ), child: Column( - crossAxisAlignment: align, + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, 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: [ - // Sender name in group chats - if (!isMe && message.author != null) ...[ - Text( - message.author!, - style: const TextStyle( - color: AppTheme.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - ], - - // Media widget - if (message.hasMedia) ...[ - InteractiveMediaWidget(message: message), - const SizedBox(height: 6), - ], - - // Text + time + ACK row - Row( + // Tail + bubble + Stack( + children: [ + // Message bubble + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, children: [ - Flexible( - child: Text( - message.body, + // Sender name in group chats (incoming only) + if (!isMe && message.author != null) ...[ + Text( + message.author!, style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, + color: AppTheme.primaryDark, + fontSize: 12.5, + fontWeight: FontWeight.bold, ), ), - ), - 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), - ], - ], - ), - ), + const SizedBox(height: 2), + ], + + // Media + if (message.hasMedia) ...[ + InteractiveMediaWidget(message: message), + const SizedBox(height: 4), + ], + + // Text + time + ACK row + _buildTextTimeRow(context, isMe), ], ), - ], - ), + ), + ], ), ], ), ); } - Widget _buildAckIcon(int ack) { + Widget _buildTextTimeRow(BuildContext context, bool isMe) { + // If body is empty (media-only message), just show time+ack + final hasBody = message.body.trim().isNotEmpty; + + if (!hasBody && message.hasMedia) { + // Show only time+ack at bottom right of media + return Align( + alignment: Alignment.bottomRight, + child: _timeAckRow(context, isMe), + ); + } + + return Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.end, + children: [ + if (hasBody) + Text( + message.body, + style: TextStyle( + color: AppTheme.textPrimary(context), + fontSize: 15, + ), + ), + const SizedBox(width: 4), + _timeAckRow(context, isMe), + ], + ); + } + + Widget _timeAckRow(BuildContext context, bool isMe) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message.timestamp), + style: TextStyle( + color: AppTheme.textSecondary(context), + fontSize: 11, + ), + ), + if (isMe) ...[ + const SizedBox(width: 3), + _buildAckIcon(context, message.ack), + ], + ], + ); + } + + // ── ACK icon ────────────────────────────────────────────────────────────── + // Real WhatsApp ACK values from whatsapp-web.js: + // -1 = error + // 0 = pending (clock icon) + // 1 = sent (single grey tick ✓) + // 2 = delivered/received (double grey tick ✓✓) + // 3 = read/played (double BLUE tick ✓✓) + Widget _buildAckIcon(BuildContext context, int ack) { switch (ack) { - case 1: // Pending/Queued - return const Icon(Icons.access_time, - size: 13, color: AppTheme.textSecondary); - case 2: // Sent (single grey tick) - return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary); - case 3: // Delivered (double grey tick) - return const Icon(Icons.done_all, - size: 15, color: AppTheme.textSecondary); - case 4: // Read (double blue tick) - return const Icon(Icons.done_all, size: 15, color: Colors.blue); - case 5: // Played audio/video (double blue with wave icon) - return const Icon(Icons.done_all, size: 15, color: Colors.blue); + case -1: + case 0: + // Pending — clock + return Icon(Icons.access_time_rounded, + size: 14, color: AppTheme.textSecondary(context)); + case 1: + // Sent — single grey tick + return Icon(Icons.check_rounded, + size: 16, color: AppTheme.textSecondary(context)); + case 2: + // Delivered — double grey tick + return Icon(Icons.done_all_rounded, + size: 16, color: AppTheme.textSecondary(context)); + case 3: + // Read — double blue tick + return const Icon(Icons.done_all_rounded, + size: 16, color: AppTheme.blueTick); default: return const SizedBox.shrink(); } @@ -291,7 +344,7 @@ class _InteractiveMediaWidgetState extends State { width: 140, alignment: Alignment.center, decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), + color: Colors.black.withOpacity(0.12), borderRadius: BorderRadius.circular(8), ), child: const SizedBox( @@ -307,32 +360,50 @@ class _InteractiveMediaWidgetState extends State { return GestureDetector( onTap: _downloadMedia, child: Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), + color: Colors.black.withOpacity(0.10), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(_getIcon(), color: AppTheme.textSecondary, size: 32), - const SizedBox(width: 12), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(_getIcon(), + color: AppTheme.primary, size: 22), + ), + const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _getLabel(), - style: const TextStyle( - color: AppTheme.textPrimary, + style: TextStyle( + color: AppTheme.textPrimary(context), fontWeight: FontWeight.w500, fontSize: 13), ), const SizedBox(height: 2), - const Text( - 'Tap to download', - style: - TextStyle(color: AppTheme.textSecondary, fontSize: 10), + Row( + children: [ + Icon(Icons.download_rounded, + size: 12, + color: AppTheme.textSecondary(context)), + const SizedBox(width: 3), + Text( + 'Tap to download', + style: TextStyle( + color: AppTheme.textSecondary(context), + fontSize: 11), + ), + ], ), ], ), @@ -354,7 +425,7 @@ class _InteractiveMediaWidgetState extends State { Widget _buildDownloadedMedia(BuildContext context, String base64Data) { final bytes = base64Decode(base64Data); - // ── Image / Sticker: full-screen viewer on tap ───────────────────────── + // ── Image / Sticker ──────────────────────────────────────────────────── if (widget.message.type == "image" || widget.message.type == "sticker") { final heroTag = 'img_${widget.message.id}'; return GestureDetector( @@ -377,7 +448,7 @@ class _InteractiveMediaWidgetState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 250), + constraints: const BoxConstraints(maxHeight: 260), child: Image.memory( bytes, fit: BoxFit.cover, @@ -391,30 +462,29 @@ class _InteractiveMediaWidgetState extends State { // ── Audio / Voice Note ───────────────────────────────────────────────── if (widget.message.type == "audio") { - final durationStr = _audioDurationSeconds > 1 - ? '${(_audioDurationSeconds ~/ 60).toString().padLeft(1, '0')}:${(_audioDurationSeconds % 60).toString().padLeft(2, '0')}' - : '0:${_audioCurrentSeconds.toString().padLeft(2, '0')}'; + final totalSec = _audioDurationSeconds > 1 ? _audioDurationSeconds : _audioCurrentSeconds; + final durationStr = + '${(totalSec ~/ 60).toString().padLeft(1, '0')}:${(totalSec % 60).toString().padLeft(2, '0')}'; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: Icon( - _isPlaying - ? Icons.pause_circle_filled - : Icons.play_circle_filled, - color: AppTheme.primary, - size: 36, + CircleAvatar( + radius: 18, + backgroundColor: AppTheme.primary, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon( + _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: Colors.white, + size: 20, + ), + onPressed: () => _toggleAudioPlayback(base64Data), ), - onPressed: () => _toggleAudioPlayback(base64Data), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), ), const SizedBox(width: 8), Expanded( @@ -423,13 +493,14 @@ class _InteractiveMediaWidgetState extends State { children: [ SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: 2.5, + trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), activeTrackColor: AppTheme.primary, - inactiveTrackColor: AppTheme.surfaceLight, + inactiveTrackColor: + AppTheme.textSecondary(context).withOpacity(0.3), thumbColor: AppTheme.primary, overlayColor: AppTheme.primary.withOpacity(0.2), ), @@ -438,7 +509,8 @@ class _InteractiveMediaWidgetState extends State { onChanged: (v) async { final targetMs = (v * _audioDurationSeconds * 1000).toInt(); - await _player.seek(Duration(milliseconds: targetMs)); + await _player + .seek(Duration(milliseconds: targetMs)); }, ), ), @@ -446,10 +518,10 @@ class _InteractiveMediaWidgetState extends State { padding: const EdgeInsets.only(left: 4), child: Text( durationStr, - style: const TextStyle( - color: AppTheme.textSecondary, - fontFamily: 'monospace', - fontSize: 10, + style: TextStyle( + color: AppTheme.textSecondary(context), + fontSize: 11, + fontFeatures: const [FontFeature.tabularFigures()], ), ), ), @@ -463,21 +535,31 @@ class _InteractiveMediaWidgetState extends State { // ── Default: Document / File ─────────────────────────────────────────── return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), + color: Colors.black.withOpacity(0.10), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, - color: AppTheme.primary, size: 32), - const SizedBox(width: 12), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: const Icon(Icons.insert_drive_file_rounded, + color: AppTheme.primary, size: 22), + ), + const SizedBox(width: 10), Text( _getLabel(), - style: const TextStyle( - color: AppTheme.textPrimary, fontWeight: FontWeight.w500), + style: TextStyle( + color: AppTheme.textPrimary(context), + fontWeight: FontWeight.w500, + fontSize: 13), ), ], ), @@ -487,30 +569,30 @@ class _InteractiveMediaWidgetState extends State { IconData _getIcon() { switch (widget.message.type) { case "image": - return Icons.photo_outlined; + return Icons.photo_camera_rounded; case "video": - return Icons.videocam_outlined; + return Icons.videocam_rounded; case "audio": - return Icons.audiotrack_outlined; + return Icons.mic_rounded; case "sticker": - return Icons.emoji_emotions_outlined; + return Icons.emoji_emotions_rounded; default: - return Icons.insert_drive_file_outlined; + return Icons.insert_drive_file_rounded; } } String _getLabel() { switch (widget.message.type) { case "image": - return "Image Attachment"; + return "Photo"; case "video": - return "Video Attachment"; + return "Video"; case "audio": - return "Audio / Voice Note"; + return "Voice note"; case "sticker": - return "Sticker Attachment"; + return "Sticker"; default: - return "File Attachment"; + return "File"; } } } diff --git a/whatsapp_bridge/server.js b/whatsapp_bridge/server.js index b6e50c4..66cc2ee 100644 --- a/whatsapp_bridge/server.js +++ b/whatsapp_bridge/server.js @@ -178,7 +178,8 @@ async function formatChat(chat) { body: chat.lastMessage.body || '', timestamp: chat.lastMessage.timestamp || Math.floor(Date.now() / 1000), fromMe: chat.lastMessage.fromMe || false, - hasMedia: chat.lastMessage.hasMedia || false + hasMedia: chat.lastMessage.hasMedia || false, + ack: chat.lastMessage.ack || 0 }; }