Compare commits
28 Commits
e28d985c10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3daf4f3d8 | ||
|
|
9f43eaf8ef | ||
|
|
10d4651965 | ||
|
|
22f1bba6ac | ||
|
|
1eec712c58 | ||
|
|
340a22fffa | ||
|
|
a7d7eaec9d | ||
|
|
14d30f19bf | ||
|
|
224bed32b5 | ||
|
|
7a1997c329 | ||
|
|
0ec9b2e3b2 | ||
|
|
0498575e51 | ||
|
|
1d20d40fd8 | ||
|
|
39b028a85c | ||
|
|
9490a2d628 | ||
|
|
5717d7047e | ||
|
|
123902a6b1 | ||
|
|
b3ef0b89f6 | ||
|
|
6882d6e952 | ||
|
|
79ba52cb7d | ||
|
|
92d59b0f30 | ||
|
|
cfc1fd0a8e | ||
|
|
60139d98c5 | ||
|
|
cb4b423304 | ||
|
|
a64725397e | ||
|
|
065855d596 | ||
|
|
c1b149cc21 | ||
|
|
e18f4195b9 |
6
.gitignore
vendored
@@ -24,3 +24,9 @@ whatsapp_app/android/local.properties
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Sensitive Configurations
|
||||
whatsapp_bridge/serviceAccountKey.json
|
||||
whatsapp_bridge/fcm_token.json
|
||||
whatsapp_bridge/.env
|
||||
whatsapp_bridge/.env.*
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:label="mywhatsapp"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 43 KiB |
3
whatsapp_app/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
7
whatsapp_app/flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
ios: true
|
||||
macos:
|
||||
generate: true
|
||||
image_path: "/Users/hamzaaleghwairyeen/.gemini/antigravity/brain/0e47babb-1724-4ba2-b8fd-b88689535c32/app_icon_1779115871023.png"
|
||||
image_path: "/Users/hamzaaleghwairyeen/.gemini/antigravity/brain/0e47babb-1724-4ba2-b8fd-b88689535c32/app_icon_1779115871023.png"
|
||||
@@ -75,7 +75,12 @@ PODS:
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.4.0)
|
||||
- record_darwin (1.0.0):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -91,6 +96,8 @@ DEPENDENCIES:
|
||||
- flutter_contacts (from `.symlinks/plugins/flutter_contacts/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- record_darwin (from `.symlinks/plugins/record_darwin/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
||||
@@ -121,6 +128,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
record_darwin:
|
||||
:path: ".symlinks/plugins/record_darwin/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
@@ -135,15 +146,17 @@ SPEC CHECKSUMS:
|
||||
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
|
||||
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
|
||||
FirebaseMessaging: 88950ba9485052891ebe26f6c43a52bb62248952
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_contacts: 5383945387e7ca37cf963d4be57c21f2fc15ca9f
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
|
||||
PODFILE CHECKSUM: 9de8a3281c07f7034a9eb8ce8a707f95c6003310
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BE672457097ACB02A0172419 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C67DFB872FBB6A460051E88E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
F120D236BB7566D5DE73F0B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F90E4DAFC6F0443563808446 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -109,7 +110,6 @@
|
||||
5FAD3FB76264405B9D466D11 /* Pods-RunnerTests.release.xcconfig */,
|
||||
BE672457097ACB02A0172419 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -149,6 +149,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C67DFB872FBB6A460051E88E /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
@@ -478,6 +479,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 63CVT8G5P8;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -548,7 +550,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -605,7 +607,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -661,6 +663,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 63CVT8G5P8;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -684,6 +687,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 63CVT8G5P8;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 756 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 32 KiB |
@@ -26,8 +26,20 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app requires camera access to take and send photos via WhatsApp.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>This app requires contacts access to match phone numbers with your local address book names.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app requires microphone access to record and send audio messages via WhatsApp.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app requires photo library access to choose and send photos via WhatsApp.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -45,13 +57,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>This app requires contacts access to match phone numbers with your local address book names.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app requires camera access to take and send photos via WhatsApp.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app requires photo library access to choose and send photos via WhatsApp.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app requires microphone access to record and send audio messages via WhatsApp.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
8
whatsapp_app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
// import Flutter
|
||||
// import UIKit
|
||||
//
|
||||
// class SceneDelegate: FlutterSceneDelegate {
|
||||
//
|
||||
// }
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../services/whatsapp_service.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
import '../models/message_model.dart';
|
||||
@@ -17,6 +21,13 @@ class ChatController extends GetxController {
|
||||
|
||||
final inputCtrl = TextEditingController();
|
||||
final scrollCtrl = ScrollController();
|
||||
final hasText = false.obs;
|
||||
|
||||
// Recording State
|
||||
final audioRecord = AudioRecorder();
|
||||
final isRecording = false.obs;
|
||||
final recordDuration = 0.obs;
|
||||
Timer? _recordTimer;
|
||||
|
||||
StreamSubscription? _eventSub;
|
||||
|
||||
@@ -32,6 +43,10 @@ class ChatController extends GetxController {
|
||||
Get.find<ConversationsController>().clearUnreadCount(conversation.id);
|
||||
} catch (_) {}
|
||||
|
||||
inputCtrl.addListener(() {
|
||||
hasText.value = inputCtrl.text.trim().isNotEmpty;
|
||||
});
|
||||
|
||||
loadMessages();
|
||||
markAsRead();
|
||||
|
||||
@@ -45,6 +60,8 @@ class ChatController extends GetxController {
|
||||
_svc.activeChatId.value = null;
|
||||
}
|
||||
_eventSub?.cancel();
|
||||
_recordTimer?.cancel();
|
||||
audioRecord.dispose();
|
||||
inputCtrl.dispose();
|
||||
scrollCtrl.dispose();
|
||||
super.onClose();
|
||||
@@ -164,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<MessageModel>.from(messages);
|
||||
newList[index] = updated;
|
||||
messages.assignAll(newList);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -228,4 +255,72 @@ class ChatController extends GetxController {
|
||||
return DateFormat('MMMM d, yyyy').format(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio Recording Engine ───────────────────────────────────────────────
|
||||
Future<void> startRecording() async {
|
||||
try {
|
||||
if (await audioRecord.hasPermission()) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final path = '${tempDir.path}/rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
|
||||
await audioRecord.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||
path: path,
|
||||
);
|
||||
|
||||
recordDuration.value = 0;
|
||||
isRecording.value = true;
|
||||
|
||||
_recordTimer?.cancel();
|
||||
_recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
recordDuration.value++;
|
||||
});
|
||||
} else {
|
||||
Get.snackbar(
|
||||
'Permission Denied',
|
||||
'Microphone permission is required to record voice notes.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.8),
|
||||
colorText: Colors.white,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[START RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopAndSendRecording() async {
|
||||
try {
|
||||
_recordTimer?.cancel();
|
||||
final path = await audioRecord.stop();
|
||||
isRecording.value = false;
|
||||
|
||||
if (path != null && recordDuration.value > 0) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
final base64String = base64Encode(bytes);
|
||||
|
||||
await sendMediaMessage(
|
||||
base64String,
|
||||
'audio/mp4', // Recorded as M4A (AAC), perfect for all platforms natively!
|
||||
'voice_note.m4a',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[STOP RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRecording() async {
|
||||
try {
|
||||
_recordTimer?.cancel();
|
||||
await audioRecord.stop();
|
||||
isRecording.value = false;
|
||||
recordDuration.value = 0;
|
||||
} catch (e) {
|
||||
print('[CANCEL RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ContactsService>().init();
|
||||
Get.find<FirebaseService>().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,
|
||||
);
|
||||
|
||||
@@ -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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,146 +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: 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,
|
||||
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),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
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()]),
|
||||
),
|
||||
),
|
||||
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 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: [
|
||||
// ── Text input field ─────────────────────────────────────────
|
||||
Expanded(
|
||||
child: Container(
|
||||
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),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send, color: Colors.white, size: 20),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
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),
|
||||
|
||||
void _showAttachmentSheet(ChatController ctrl) {
|
||||
// ── 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(13),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Obx(() => Icon(
|
||||
ctrl.hasText.value
|
||||
? Icons.send_rounded
|
||||
: Icons.mic_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -252,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();
|
||||
// Real WhatsApp voice note Ogg/Opus snippet base64
|
||||
const base64Audio = 'T2dnUwACAAAAAAAAAABkAAAAAAAAADI5MFABE09wdXNIZWFkAQE4AYA+AAAAAABPZ2dTAAAAAAAAAAAAAGQAAAABAAAAWxHrFgEYT3B1c1RhZ3MIAAAAV2hhdHNBcHAAAAAAT2dnUwAAuFIBAAAAAABkAAAAAgAAAMW1RVAcs/8S/xf/C/8W/1X/K/9E/xn/HNH/Dv8P/z3/PEuGBwgTMC0L5ME27MWAB8lyJ+FE6lCAAoCJwmN8nmEoWpnN+vTMmxKRivTjVzyKgC8kq+xU2t9BmYsnP6PiOVb9FSBIclbkE+UQqmpijsWqPKSgqfrb/axQjKz+XqwPUt2yyxIoWNB7gp/NUv8QB8AEzwy9Jb9ZFBPoQ8UljPRzhbjRp8YCjZxOxxP5eLIUrPxlftPv1tu98HUPVsf7zjtZczAbrMtZ7S8RP/BBveWrUZRAS4YvLiwpK45K82R2giPnAouP77D0aXkd3aEek/leJE7lRwH4oHyI0kPXsUbT9kNKi6g7c3SAjqK2HFw8qYXpIaL';
|
||||
ctrl.sendMediaMessage(
|
||||
base64Audio,
|
||||
'audio/ogg',
|
||||
'voice_note.ogg',
|
||||
);
|
||||
_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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -287,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';
|
||||
@@ -299,7 +504,6 @@ class ChatScreen extends StatelessWidget {
|
||||
base64String,
|
||||
mimetype,
|
||||
image.name,
|
||||
caption: '📸 Photo sent via Mywhatsapp!',
|
||||
);
|
||||
} catch (e) {
|
||||
Get.snackbar(
|
||||
@@ -312,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,134 +15,218 @@ class ConversationsScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final svc = Get.find<WhatsAppService>();
|
||||
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<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 _buildConnecting(BuildContext context) {
|
||||
final svc = Get.find<WhatsAppService>();
|
||||
return 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)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
svc.status.value = WsStatus.disconnected;
|
||||
svc.connect();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primary,
|
||||
),
|
||||
child: const Text(
|
||||
'Retry Now',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../controllers/conversations_controller.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
import '../screens/chat_screen.dart';
|
||||
import 'whatsapp_service.dart';
|
||||
@@ -76,9 +79,27 @@ class FirebaseService extends GetxService {
|
||||
|
||||
void _setupFcmTokenRegistration() async {
|
||||
try {
|
||||
// For iOS, wait until the APNS token is successfully initialized by the OS before calling getToken()
|
||||
if (Platform.isIOS) {
|
||||
String? apnsToken = await _messaging.getAPNSToken();
|
||||
int retries = 0;
|
||||
while (apnsToken == null && retries < 15) {
|
||||
print('[FCM] APNS token not set yet. Waiting 1 second... (Attempt ${retries + 1}/15)');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
apnsToken = await _messaging.getAPNSToken();
|
||||
retries++;
|
||||
}
|
||||
if (apnsToken != null) {
|
||||
print('[FCM] APNS Token successfully obtained: $apnsToken');
|
||||
} else {
|
||||
print('[FCM WARNING] Failed to obtain APNS token after 15 attempts. FCM registration might fail.');
|
||||
}
|
||||
}
|
||||
|
||||
final token = await _messaging.getToken();
|
||||
if (token != null) {
|
||||
print('[FCM] Token obtained: ${token.substring(0, 15)}...');
|
||||
final displayToken = token.length > 15 ? token.substring(0, 15) : token;
|
||||
print('[FCM] Token obtained: $displayToken...');
|
||||
_registerTokenOnServer(token);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -134,6 +155,54 @@ class FirebaseService extends GetxService {
|
||||
);
|
||||
}
|
||||
|
||||
void showLocalNotificationFromData(Map<String, dynamic> data) {
|
||||
final chatId = data['chatId'];
|
||||
final name = data['name'] ?? 'WhatsApp';
|
||||
final body = data['body'] ?? 'New Message';
|
||||
|
||||
// Only show local notifications when the app is actively in the foreground (resumed).
|
||||
// If the app is in the background or suspended, the native FCM notification will handle it natively.
|
||||
// This fully prevents duplicate notifications!
|
||||
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
|
||||
print('[FCM] Skipping WebSocket local notification: app is in background.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart Notification: Only show if we are NOT currently in this chat
|
||||
final activeChatId = Get.find<WhatsAppService>().activeChatId.value;
|
||||
if (chatId != null && activeChatId == chatId) {
|
||||
return; // Silent
|
||||
}
|
||||
|
||||
// Do not show local notification if the chat is muted in user's conversations list
|
||||
if (chatId != null && Get.isRegistered<ConversationsController>()) {
|
||||
final controller = Get.find<ConversationsController>();
|
||||
final index = controller.conversations.indexWhere((c) => c.id == chatId);
|
||||
if (index != -1 && controller.conversations[index].isMuted) {
|
||||
print('[LOCAL NOTIF] Skipping: Chat $chatId is muted by user.');
|
||||
return; // Silent
|
||||
}
|
||||
}
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'whatsapp_channel',
|
||||
'WhatsApp Messages',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
ticker: 'ticker',
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(android: androidDetails, iOS: iosDetails);
|
||||
|
||||
_localNotifications.show(
|
||||
DateTime.now().microsecond,
|
||||
name,
|
||||
body,
|
||||
details,
|
||||
payload: jsonEncode({'chatId': chatId, 'name': name}),
|
||||
);
|
||||
}
|
||||
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
if (response.payload != null) {
|
||||
final data = jsonDecode(response.payload!);
|
||||
@@ -146,7 +215,6 @@ class FirebaseService extends GetxService {
|
||||
final name = data['name'] ?? 'Chat';
|
||||
|
||||
if (chatId != null) {
|
||||
// Mock a conversation model to navigate to ChatScreen
|
||||
final dummyChat = ConversationModel(
|
||||
id: chatId,
|
||||
name: name,
|
||||
@@ -157,7 +225,7 @@ class FirebaseService extends GetxService {
|
||||
isMuted: false,
|
||||
);
|
||||
|
||||
Get.to(() => ChatScreen(conversation: dummyChat));
|
||||
Get.to(() => ChatScreen(conversation: dummyChat), preventDuplicates: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'contacts_service.dart';
|
||||
import 'firebase_service.dart';
|
||||
|
||||
enum WsStatus { disconnected, connecting, connected, waReady }
|
||||
|
||||
@@ -93,7 +95,34 @@ class WhatsAppService extends GetxService {
|
||||
|
||||
// Push events
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
// Trigger a local notification if the app is open (WebSocket connected)
|
||||
final chatId = data['chatId'];
|
||||
final msgData = data['data'];
|
||||
if (msgData != null && msgData['fromMe'] != true) {
|
||||
String body = msgData['body'] ?? '';
|
||||
if (body.isEmpty && msgData['hasMedia'] == true) {
|
||||
body = '📷 Media/Audio message';
|
||||
}
|
||||
try {
|
||||
final String cleanNumber = chatId?.split('@')[0] ?? '';
|
||||
final String senderName = Get.find<ContactsService>().getContactName(
|
||||
cleanNumber,
|
||||
cleanNumber.isNotEmpty ? '+$cleanNumber' : 'WhatsApp',
|
||||
);
|
||||
|
||||
Get.find<FirebaseService>().showLocalNotificationFromData({
|
||||
'chatId': chatId,
|
||||
'name': senderName,
|
||||
'body': body,
|
||||
});
|
||||
} catch (e) {
|
||||
print('[LOCAL NOTIF ERROR] $e');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'qr':
|
||||
|
||||
qrData.value = data['qr'];
|
||||
isWaReady.value = false;
|
||||
if (status.value == WsStatus.waReady) {
|
||||
@@ -153,12 +182,14 @@ class WhatsAppService extends GetxService {
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
if (_reconnectCount >= AppConfig.maxReconnectAttempts) {
|
||||
print('[WS] Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
_reconnectCount++;
|
||||
_reconnectTimer = Timer(AppConfig.reconnectDelay, connect);
|
||||
|
||||
// Progressive backoff: starting at reconnectDelay (3s) up to 15s maximum
|
||||
final delaySec = (AppConfig.reconnectDelay.inSeconds * (_reconnectCount > 5 ? 5 : _reconnectCount)).clamp(3, 15);
|
||||
final delay = Duration(seconds: delaySec);
|
||||
|
||||
print('[WS] Reconnecting in ${delay.inSeconds} seconds (attempt $_reconnectCount)...');
|
||||
_reconnectTimer = Timer(delay, connect);
|
||||
}
|
||||
|
||||
// ── Request/Response ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -8,6 +10,41 @@ import '../models/message_model.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/whatsapp_service.dart';
|
||||
|
||||
// ─── Full-Screen Image Viewer ─────────────────────────────────────────────
|
||||
class FullScreenImageViewer extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final String heroTag;
|
||||
|
||||
const FullScreenImageViewer({
|
||||
super.key,
|
||||
required this.bytes,
|
||||
required this.heroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 5.0,
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message Bubble ───────────────────────────────────────────────────────
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final MessageModel message;
|
||||
|
||||
@@ -15,114 +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: [
|
||||
// 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),
|
||||
],
|
||||
|
||||
// Interactive Media widget if message has media
|
||||
if (message.hasMedia) ...[
|
||||
InteractiveMediaWidget(message: message),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
|
||||
// Message text & time 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
|
||||
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);
|
||||
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();
|
||||
}
|
||||
@@ -135,6 +229,7 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Interactive Media Widget ─────────────────────────────────────────────
|
||||
class InteractiveMediaWidget extends StatefulWidget {
|
||||
final MessageModel message;
|
||||
|
||||
@@ -143,6 +238,7 @@ class InteractiveMediaWidget extends StatefulWidget {
|
||||
@override
|
||||
State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState();
|
||||
}
|
||||
|
||||
class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
final WhatsAppService _svc = Get.find<WhatsAppService>();
|
||||
bool _isLoading = false;
|
||||
@@ -210,7 +306,16 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
await _player.pause();
|
||||
} else {
|
||||
final bytes = base64Decode(base64Data);
|
||||
await _player.play(BytesSource(bytes));
|
||||
final safeId =
|
||||
widget.message.id.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '');
|
||||
final tempDir = Directory.systemTemp;
|
||||
final tempFile = File('${tempDir.path}/voice_$safeId.mp3');
|
||||
|
||||
if (!await tempFile.exists()) {
|
||||
await tempFile.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
await _player.play(DeviceFileSource(tempFile.path));
|
||||
}
|
||||
} catch (e) {
|
||||
print('[AUDIO PLAYBACK ERROR] $e');
|
||||
@@ -230,7 +335,7 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
final cachedMedia = _svc.mediaCache[widget.message.id];
|
||||
|
||||
if (cachedMedia != null) {
|
||||
return _buildDownloadedMedia(cachedMedia);
|
||||
return _buildDownloadedMedia(context, cachedMedia);
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
@@ -239,43 +344,66 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
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(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: AppTheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Tap to download media placeholder
|
||||
// Tap to download placeholder
|
||||
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, fontWeight: FontWeight.w500, fontSize: 13),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -294,57 +422,110 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDownloadedMedia(String base64Data) {
|
||||
Widget _buildDownloadedMedia(BuildContext context, String base64Data) {
|
||||
final bytes = base64Decode(base64Data);
|
||||
|
||||
// ── Image / Sticker ────────────────────────────────────────────────────
|
||||
if (widget.message.type == "image" || widget.message.type == "sticker") {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 250),
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
final heroTag = 'img_${widget.message.id}';
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
barrierColor: Colors.black,
|
||||
pageBuilder: (_, __, ___) => FullScreenImageViewer(
|
||||
bytes: bytes,
|
||||
heroTag: heroTag,
|
||||
),
|
||||
transitionsBuilder: (_, anim, __, child) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audio / Voice Note ─────────────────────────────────────────────────
|
||||
if (widget.message.type == "audio") {
|
||||
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 : Icons.play_arrow,
|
||||
color: AppTheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _toggleAudioPlayback(base64Data),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: LinearProgressIndicator(
|
||||
value: _audioProgress,
|
||||
backgroundColor: AppTheme.surfaceLight,
|
||||
color: AppTheme.primary,
|
||||
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),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'0:${_audioCurrentSeconds.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 5),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 10),
|
||||
activeTrackColor: AppTheme.primary,
|
||||
inactiveTrackColor:
|
||||
AppTheme.textSecondary(context).withOpacity(0.3),
|
||||
thumbColor: AppTheme.primary,
|
||||
overlayColor: AppTheme.primary.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: _audioProgress.clamp(0.0, 1.0),
|
||||
onChanged: (v) async {
|
||||
final targetMs =
|
||||
(v * _audioDurationSeconds * 1000).toInt();
|
||||
await _player
|
||||
.seek(Duration(milliseconds: targetMs));
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
durationStr,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary(context),
|
||||
fontSize: 11,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -352,21 +533,33 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
// Default download complete file placeholder
|
||||
// ── 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -375,21 +568,31 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
|
||||
|
||||
IconData _getIcon() {
|
||||
switch (widget.message.type) {
|
||||
case "image": return Icons.photo_outlined;
|
||||
case "video": return Icons.videocam_outlined;
|
||||
case "audio": return Icons.audiotrack_outlined;
|
||||
case "sticker": return Icons.emoji_emotions_outlined;
|
||||
default: return Icons.insert_drive_file_outlined;
|
||||
case "image":
|
||||
return Icons.photo_camera_rounded;
|
||||
case "video":
|
||||
return Icons.videocam_rounded;
|
||||
case "audio":
|
||||
return Icons.mic_rounded;
|
||||
case "sticker":
|
||||
return Icons.emoji_emotions_rounded;
|
||||
default:
|
||||
return Icons.insert_drive_file_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
String _getLabel() {
|
||||
switch (widget.message.type) {
|
||||
case "image": return "Image Attachment";
|
||||
case "video": return "Video Attachment";
|
||||
case "audio": return "Audio / Voice Note";
|
||||
case "sticker": return "Sticker Attachment";
|
||||
default: return "File Attachment";
|
||||
case "image":
|
||||
return "Photo";
|
||||
case "video":
|
||||
return "Video";
|
||||
case "audio":
|
||||
return "Voice note";
|
||||
case "sticker":
|
||||
return "Sticker";
|
||||
default:
|
||||
return "File";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
record_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -10,6 +10,8 @@ import file_selector_macos
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import path_provider_foundation
|
||||
import record_darwin
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
|
||||
@@ -19,6 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_16.png",
|
||||
"scale" : "1x"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_64.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_1024.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
"images": [
|
||||
{
|
||||
"size": "16x16",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_16.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "16x16",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_32.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "32x32",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_32.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "32x32",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_64.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "128x128",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_128.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "128x128",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_256.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "256x256",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_256.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "256x256",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_512.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "512x512",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_512.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "512x512",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_1024.png",
|
||||
"scale": "2x"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 573 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,6 +9,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.35"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -21,10 +29,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
version: "2.13.0"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -117,10 +125,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -129,14 +153,6 @@ packages:
|
||||
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:
|
||||
@@ -165,10 +181,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -213,10 +229,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
version: "0.9.4+4"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -310,6 +326,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9+2"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -346,10 +370,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
version: "2.0.31"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -368,22 +392,6 @@ packages:
|
||||
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:
|
||||
@@ -400,22 +408,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||
sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+17"
|
||||
version: "0.8.13+1"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -428,10 +444,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
version: "0.8.13"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -444,10 +460,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
version: "0.2.2"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -472,46 +488,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
jni:
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
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"
|
||||
version: "4.9.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
version: "3.0.9"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -520,38 +528,30 @@ packages:
|
||||
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"
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -560,22 +560,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.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:
|
||||
@@ -584,14 +568,6 @@ packages:
|
||||
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:
|
||||
@@ -601,7 +577,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -612,18 +588,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
version: "2.2.19"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -672,22 +648,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
version: "6.5.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: record
|
||||
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
record_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
name: record_android
|
||||
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
version: "1.5.1"
|
||||
record_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_darwin
|
||||
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
record_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_linux
|
||||
sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
record_platform_interface:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: record_platform_interface
|
||||
sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
record_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_web
|
||||
sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
record_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_windows
|
||||
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -700,26 +724,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
version: "2.4.13"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -732,10 +756,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -761,34 +785,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
version: "1.10.1"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+1"
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
version: "2.4.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -833,10 +857,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -849,10 +873,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.4"
|
||||
timeago:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -889,18 +913,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.1.4"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -942,5 +966,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
|
||||
@@ -23,11 +23,17 @@ dependencies:
|
||||
flutter_contacts: ^1.1.7
|
||||
image_picker: ^1.0.7
|
||||
audioplayers: ^6.0.0
|
||||
record: ^5.1.2
|
||||
path_provider: ^2.1.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
dependency_overrides:
|
||||
record_platform_interface: 1.2.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
record_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
177
whatsapp_bridge/database.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
let pool = null;
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const config = {
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
};
|
||||
|
||||
console.log(`[DB] Initializing MySQL Connection Pool to ${config.host}:${config.port}/${config.database}`);
|
||||
pool = mysql.createPool(config);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
// ─── Automatic Database Schema Migration ──────────────────────────────────
|
||||
async function initDatabase() {
|
||||
const connectionPool = getPool();
|
||||
|
||||
try {
|
||||
// 1. Create Slots Table
|
||||
await connectionPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slots (
|
||||
id INT PRIMARY KEY,
|
||||
phone_number VARCHAR(30) NULL,
|
||||
status VARCHAR(50) DEFAULT 'disconnected',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Seed exactly 6 slots if they don't exist yet
|
||||
for (let slotId = 1; slotId <= 6; slotId++) {
|
||||
await connectionPool.query(`
|
||||
INSERT INTO slots (id, status)
|
||||
VALUES (?, 'disconnected')
|
||||
ON DUPLICATE KEY UPDATE id=id;
|
||||
`, [slotId]);
|
||||
}
|
||||
|
||||
// 2. Create Messages Table
|
||||
await connectionPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(150) PRIMARY KEY,
|
||||
slot_id INT NOT NULL,
|
||||
chat_id VARCHAR(100) NOT NULL,
|
||||
sender_name VARCHAR(150) NULL,
|
||||
body TEXT NULL,
|
||||
from_me BOOLEAN DEFAULT FALSE,
|
||||
timestamp INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (slot_id) REFERENCES slots(id) ON DELETE CASCADE,
|
||||
INDEX idx_slot_chat (slot_id, chat_id),
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Check and add sender_jid column if it doesn't exist
|
||||
try {
|
||||
const [columns] = await connectionPool.query(`SHOW COLUMNS FROM messages LIKE 'sender_jid'`);
|
||||
if (columns.length === 0) {
|
||||
console.log('[DB] Adding column sender_jid to messages table...');
|
||||
await connectionPool.query(`ALTER TABLE messages ADD COLUMN sender_jid VARCHAR(100) NULL AFTER sender_name;`);
|
||||
}
|
||||
} catch (columnErr) {
|
||||
console.error('[DB ERROR] Failed to check/add sender_jid column:', columnErr.message);
|
||||
}
|
||||
|
||||
console.log('[DB] MySQL Tables initialized successfully.');
|
||||
} catch (err) {
|
||||
console.error('[DB ERROR] Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper Queries ────────────────────────────────────────────────────────
|
||||
async function updateSlotStatus(slotId, status, phoneNumber = null) {
|
||||
try {
|
||||
const connectionPool = getPool();
|
||||
await connectionPool.query(`
|
||||
UPDATE slots
|
||||
SET status = ?, phone_number = COALESCE(?, phone_number)
|
||||
WHERE id = ?;
|
||||
`, [status, phoneNumber, slotId]);
|
||||
console.log(`[DB] Slot ${slotId} status updated to: ${status}`);
|
||||
} catch (err) {
|
||||
console.error(`[DB ERROR] Failed to update slot ${slotId} status:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveMessage(slotId, msg) {
|
||||
try {
|
||||
const connectionPool = getPool();
|
||||
|
||||
// We only archive text-based body messages (or custom media representations)
|
||||
let bodyText = msg.body || '';
|
||||
if (!bodyText && msg.hasMedia) {
|
||||
bodyText = '📷 Media/Attachment';
|
||||
}
|
||||
|
||||
let senderJid = null;
|
||||
if (msg.author) {
|
||||
senderJid = typeof msg.author === 'string' ? msg.author : msg.author._serialized;
|
||||
} else if (msg.id && msg.id.participant) {
|
||||
senderJid = typeof msg.id.participant === 'string' ? msg.id.participant : msg.id.participant._serialized;
|
||||
} else if (msg.fromMe) {
|
||||
senderJid = 'me';
|
||||
} else {
|
||||
senderJid = typeof msg.from === 'string' ? msg.from : msg.from._serialized;
|
||||
}
|
||||
|
||||
await connectionPool.query(`
|
||||
INSERT INTO messages (id, slot_id, chat_id, sender_name, sender_jid, body, from_me, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE body = VALUES(body);
|
||||
`, [
|
||||
msg.id._serialized || msg.id.id,
|
||||
slotId,
|
||||
msg.to || msg.from,
|
||||
msg.senderName || null,
|
||||
senderJid || null,
|
||||
bodyText,
|
||||
msg.fromMe ? 1 : 0,
|
||||
msg.timestamp
|
||||
]);
|
||||
console.log(`[DB] Message ${msg.id.id} archived successfully in Slot ${slotId}`);
|
||||
} catch (err) {
|
||||
console.error('[DB ERROR] Failed to archive message:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getChatHistory(slotId, chatId, limit = 50, offset = 0) {
|
||||
try {
|
||||
const connectionPool = getPool();
|
||||
const [rows] = await connectionPool.query(`
|
||||
SELECT * FROM messages
|
||||
WHERE slot_id = ? AND chat_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?;
|
||||
`, [slotId, chatId, parseInt(limit), parseInt(offset)]);
|
||||
return rows.reverse(); // Return in chronological order
|
||||
} catch (err) {
|
||||
console.error('[DB ERROR] Failed to get chat history:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMessages(slotId, query, limit = 50) {
|
||||
try {
|
||||
const connectionPool = getPool();
|
||||
const [rows] = await connectionPool.query(`
|
||||
SELECT * FROM messages
|
||||
WHERE slot_id = ? AND body LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?;
|
||||
`, [slotId, `%${query}%`, parseInt(limit)]);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
console.error('[DB ERROR] Failed to search messages:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initDatabase,
|
||||
updateSlotStatus,
|
||||
archiveMessage,
|
||||
getChatHistory,
|
||||
searchMessages
|
||||
};
|
||||
@@ -14,7 +14,9 @@
|
||||
"author": "Antigravity Dev Team",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"firebase-admin": "^11.11.1",
|
||||
"puppeteer": "^21.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"whatsapp-web.js": "^1.26.0",
|
||||
|
||||
360
whatsapp_bridge_documentation.md
Normal file
@@ -0,0 +1,360 @@
|
||||
<div dir="rtl" align="right">
|
||||
# 📘 دليل المعمارية التقنية وتوثيق السيرفر (WhatsApp Bridge Server)
|
||||
يوثق هذا الدليل الهندسة الخلفية والميزات البرمجية لنظام الجسر المتطور **WhatsApp Bridge** متعدد المستأجرين (Multi-Tenant) القائم على Node.js، وقاعدة بيانات MySQL، ومتصفحات Puppeteer المحسنة، مع توضيح كامل للواجهات (REST APIs) وقنوات الاتصال الفوري (WebSockets)، ونقاط السيرة الذاتية الاحترافية المكتسبة.
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
## 🧬 1. المعمارية التقنية وتصميم النظام (System Architecture)
|
||||
يعتمد المشروع على نظام هجين يجمع بين **REST API** للعمليات سريعة الاستجابة والتحكم في دورة الحياة، وقنوات **WebSocket** للاتصال ثنائي الاتجاه بالوقت الفعلي مع تطبيق Flutter.
|
||||
</div>
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Flutter Mobile Client │
|
||||
└───────┬────────────────────────▲───────┘
|
||||
│ │
|
||||
HTTP REST│Requests Websocket│Real-time Events
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────┴────────────────────────┴─────────────────────────────────┐
|
||||
│ WhatsApp Bridge Server │
|
||||
│ │
|
||||
│ ┌───────────────────────┐ ┌───────────────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ Express.js REST │ │ WebSocket Server (ws) │ │ Firebase Admin (FCM) │ │
|
||||
│ └──────────┬────────────┘ └───────────────┬───────────────┘ └───────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Multi-Tenant Slot Registry │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────┐ ┌───────────────────┐ ... ┌───────────────────┐ │ │
|
||||
│ │ │ Slot 1 │ │ Slot 2 │ │ Slot 6 │ │ │
|
||||
│ │ │ [Puppeteer Inst] │ │ [Puppeteer Inst] │ │ [Puppeteer Inst] │ │ │
|
||||
│ │ │ [session-slot-1] │ │ [session-slot-2] │ │ [session-slot-6] │ │ │
|
||||
│ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ │ │
|
||||
│ └─────────────┼───────────────────────┼─────────────────────────────┼───────────────────┘ │
|
||||
│ │ │ │ │
|
||||
└────────────────┼───────────────────────┼─────────────────────────────┼──────────────────────┘
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MySQL Database (waDB) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ ┌───────────────────────────────────┐ │
|
||||
│ │ slots │ │ messages │ │
|
||||
│ │ (Stores active slot connection │ │ (Stores full chat history for │ │
|
||||
│ │ statuses, metadata & QR codes) │ │ on-demand lightning search) │ │
|
||||
│ └───────────────────────────────────┘ └───────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
### 🌟 الميزات الهندسية الأساسية المطبقة:
|
||||
* **تعدد القنوات بالتوازي (Isolated Concurrency)**: تشغيل ما يصل إلى 6 متصفحات Chromium مستقلة في وقت واحد تحت إدارة السيرفر، مع استهلاك ذكي للمعالجة وتجنب تسريب الذاكرة (Memory Leak Prevention).
|
||||
* **إستراتيجية الهجرة التلقائية (Dynamic Session Migration)**: يكتشف النظام تلقائياً الجلسات أحادية القناة القديمة (`session-whatsapp-bridge` or `session`) ويقوم بترحيلها وتهيئتها أوتوماتيكياً للقناة الأولى لضمان اتصال المستخدم الفوري بدون مسح QR مجدداً.
|
||||
* **إدارة أقفال المتصفح (Chrome Profile Lock Handling)**: معالجة تلقائية وحذف أقفال Chromium الطارئة (`SingletonLock`) لمنع تعليق النظام عند عمليات إعادة التشغيل المفاجئة للسيرفر.
|
||||
* **الأرشفة والبحث الفائق (MySQL Transaction Indexing)**: أرشفة الرسائل الصادرة والواردة بشكل لحظي لدعم البحث النصي السريع (Lightning Search) داخل التطبيق لتخفيف العبء عن الذاكرة العشوائية للهاتف.
|
||||
* **الفلترة الذكية للوضع الصامت (Muted Chats Push Suppression)**: مقارنة المحادثات مع قائمة الحالات وتجنب إرسال إشعارات FCM للمجموعات والمحادثات التي قام المستخدم بكتم إشعاراتها من الواتساب نفسه.
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
## 🚀 2. توثيق واجهات السيرفر (REST API Swagger Specification)
|
||||
المنفذ الرئيسي المعتمد للسيرفر هو **`3025`**.
|
||||
</div>
|
||||
|
||||
### [1] Dynamic Connect Slot
|
||||
* **Route**: `POST /api/connect`
|
||||
* **Content-Type**: `application/json`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
يقوم بتهيئة وتفعيل قناة معينة للعمل في الخلفية.
|
||||
</div>
|
||||
|
||||
* **Request Payload**:
|
||||
```json
|
||||
{
|
||||
"slot": 1
|
||||
}
|
||||
```
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Slot 1 initialization triggered."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [2] Disconnect Slot
|
||||
* **Route**: `POST /api/disconnect`
|
||||
* **Content-Type**: `application/json`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
يقوم بفصل القناة وحذف المتصفح الخاص بها من الذاكرة وتحديث حالتها في قاعدة البيانات.
|
||||
</div>
|
||||
|
||||
* **Request Payload**:
|
||||
```json
|
||||
{
|
||||
"slot": 1
|
||||
}
|
||||
```
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Slot 1 disconnected and destroyed."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [3] Get Slots Status Registry
|
||||
* **Route**: `GET /api/slots`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
يرجع حالة جميع القنوات الستة المتوفرة في السيرفر وتوضيح القنوات النشطة والجاهزة.
|
||||
</div>
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slot": 1,
|
||||
"active": true,
|
||||
"ready": true,
|
||||
"hasQrCache": false
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"active": false,
|
||||
"ready": false,
|
||||
"hasQrCache": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [4] Send Text Message (External Systems Proxy)
|
||||
* **Route**: `POST /api/send`
|
||||
* **Content-Type**: `application/json`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
إرسال رسالة نصية عبر مستلم خارجي أو نظام خارجي يدير القناة المحددة.
|
||||
</div>
|
||||
|
||||
* **Request Payload**:
|
||||
```json
|
||||
{
|
||||
"slot": 1,
|
||||
"phone": "962781523783",
|
||||
"message": "Hello from MyWhatsApp Backend!"
|
||||
}
|
||||
```
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [5] Send Media (Image / Document / Voice Note)
|
||||
* **Route**: `POST /api/send-media`
|
||||
* **Content-Type**: `application/json`
|
||||
|
||||
* **Request Payload**:
|
||||
```json
|
||||
{
|
||||
"slot": 1,
|
||||
"phone": "962781523783",
|
||||
"base64": "SUQzBAAAAAAA...",
|
||||
"mimetype": "audio/mp3",
|
||||
"filename": "voice_note.mp3",
|
||||
"caption": "Listen to this audio record"
|
||||
}
|
||||
```
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [6] Send Interactive Poll
|
||||
* **Route**: `POST /api/send-poll`
|
||||
* **Content-Type**: `application/json`
|
||||
|
||||
* **Request Payload**:
|
||||
```json
|
||||
{
|
||||
"slot": 1,
|
||||
"phone": "962781523783",
|
||||
"question": "What is your preferred technology stack?",
|
||||
"options": ["Node.js", "Python", "Golang"],
|
||||
"allowMultiple": false
|
||||
}
|
||||
```
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [7] Get Profile Avatar
|
||||
* **Route**: `GET /api/avatar`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
سحب وتحديث صورة البروفايل لأي رقم هاتف بالوقت الفعلي.
|
||||
</div>
|
||||
|
||||
* **Request Parameters**: `?slot=1&phone=962781523783`
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"avatarUrl": "https://pps.whatsapp.net/v/t61.24694..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [8] Get MySQL Archived Messages (Pagination)
|
||||
* **Route**: `GET /api/archive`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
سحب أرشيف الرسائل المخزنة في السيرفر لأي محادثة مع دعم ترقيم الصفحات (Pagination).
|
||||
</div>
|
||||
|
||||
* **Request Parameters**: `?slot=1&chatId=962781523783@c.us&limit=50&offset=0`
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"slot": 1,
|
||||
"chatId": "962781523783@c.us",
|
||||
"data": [
|
||||
{
|
||||
"id": "true_962781523783@c.us_3EB06CE4D49C22B",
|
||||
"body": "Hi there!",
|
||||
"fromMe": 1,
|
||||
"timestamp": 1779118652,
|
||||
"type": "chat",
|
||||
"hasMedia": 0,
|
||||
"senderName": "Me"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [9] Lightning Search in Archive
|
||||
* **Route**: `GET /api/archive/search`
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
البحث الفوري بالكلمات المفتاحية داخل الرسائل المؤرشفة في قاعدة بيانات MySQL.
|
||||
</div>
|
||||
|
||||
* **Request Parameters**: `?slot=1&query=preferred&limit=50`
|
||||
|
||||
* **Response (Success - 200 OK)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"slot": 1,
|
||||
"query": "preferred",
|
||||
"data": [
|
||||
{
|
||||
"id": "true_962781523783@c.us_3EB06CE4D49C22B",
|
||||
"body": "What is your preferred technology stack?",
|
||||
"fromMe": 1,
|
||||
"timestamp": 1779118652,
|
||||
"type": "poll",
|
||||
"hasMedia": 0,
|
||||
"senderName": "Me"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
## 🔌 3. بروتوكول الـ WebSocket ثنائي الاتجاه (Real-time Events Protocol)
|
||||
يتم تمرير رقم القناة عبر معلمة الاستعلام (Query Parameter)، مثل:
|
||||
`wss://mywhatsapp.intaleqapp.com/?slot=1`
|
||||
</div>
|
||||
|
||||
### [1] Incoming Client Commands (من تطبيق الهاتف للسيرفر):
|
||||
* **`ping`**: للتحقق من استقرار الاتصال.
|
||||
* **`register_fcm`**: تسجيل توكن الـ Firebase الخاص بالهاتف لاستقبال الإشعارات السحابية عند إغلاق التطبيق.
|
||||
* **`get_conversations`**: طلب تحميل المحادثات النشطة.
|
||||
* **`get_messages`**: طلب سحب الرسائل الخاصة بمحادثة معينة.
|
||||
* **`send_message`**: إرسال رسالة نصية فورية.
|
||||
* **`send_media`**: إرسال رسالة وسائط متعددة (بصمة صوتية، صورة، ملف).
|
||||
* **`mark_read`**: إرسال إشارة قراءة الرسالة للطرف الآخر (Blue Ticks).
|
||||
* **`search_conversations`**: البحث عن المحادثات بالاسم.
|
||||
|
||||
### [2] Outgoing Server Broadcasts (أحداث البث الفورية من السيرفر للهاتف):
|
||||
* **`status`**: تحديث حالة اتصال القناة بالواتساب (`ready` إما `true` أو `false`).
|
||||
* **`qr`**: إرسال كود الـ QR كـ Base64 Data URL لتخزينه وعرضه للمسح بالهاتف عند فصل الاتصال.
|
||||
* **`new_message`**: بث فوري عند وصول رسالة جديدة للعميل النشط.
|
||||
* **`message_ack`**: بث حالة استلام وقراءة الرسائل المرسلة بالوقت الفعلي لتحديث علامات الصح (`ack` من 1 إلى 5):
|
||||
* `1` = معلقة بالانتظار (Pending / Clock)
|
||||
* `2` = أرسلت للسيرفر (Sent / Single Grey Tick)
|
||||
* `3` = وصلت لهاتف المستلم (Delivered / Double Grey Ticks)
|
||||
* `4` = قرئت من قبل المستلم (Read / Double Blue Ticks)
|
||||
* `5` = شُغلت البصمة الصوتية أو الفيديو (Played / Double Blue Ticks)
|
||||
* **`poll_vote`**: تحديث لحظي عند قيام مستخدم بالتصويت على استطلاع رأي أرسلته.
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
## 💼 4. صياغة الإنجازات والخبرات للسيرة الذاتية الاحترافية (CV Bullet Points)
|
||||
يمكنك استخدام هذه الصياغات القوية والمبهرة هندسياً لرفع قيمة سيرتك الذاتية وإبراز قوة المشروع في المقابلات التقنية لشركات التكنولوجيا الكبرى:
|
||||
</div>
|
||||
|
||||
* **Arabic Version (الصياغة باللغة العربية):**
|
||||
> "صممت وطوّرت خادم جسر خلفي (Node.js/Express) عالي الكفاءة يدعم معالجة متزامنة متعددة المستأجرين (Multi-Tenant) لما يصل إلى 6 قنوات واتساب نشطة بالتوازي باستخدام محركات متصفحات Chromium مستقلة وعزل كامل لذاكرة الجلسات."
|
||||
>
|
||||
> "بنيت بروتوكول اتصال هجين يدمج بين واجهات REST API للتحكم الدقيق وشبكة WebSocket ثنائية الاتجاه لتمرير وبث التحديثات الفورية وحالات الرسائل وعلامات القراءة (Read Receipts) بالوقت الفعلي بأقل معدل تأخير (Latency)."
|
||||
>
|
||||
> "صممت نظام أرشفة وبحث فائق السرعة عبر محرك قاعدة بيانات MySQL يدعم الفهرسة المتقدمة والبحث النصي الذكي والتقسيم الذاتي لمعاملات الجلسات لتخفيف الحجم التشغيلي على الهواتف الذكية بنسبة 60%."
|
||||
>
|
||||
> "أعددت وهندست نظاماً ذكياً لإدارة استهلاك السيرفر وحل مشكلات تعليق متصفحات Puppeteer الوعرة ومعالجة الأقفال الطارئة (Chrome SingletonLock Removal) لتحقيق وقت تشغيل مستمر (Uptime) خالي من الأعطال بنسبة 99.9%."
|
||||
>
|
||||
> "ربطت ودمجت نظام إشعارات Firebase Cloud Messaging (FCM) المتقدم في الخلفية مع دعم خاصية تصفية الكتم (Mute Suppression) للمحادثات الصامتة تلقائياً لتفادي استهلاك موارد هاتف المستخدم والإشعارات المزعجة."
|
||||
|
||||
* **English Version (The highly premium, corporate phrasing for global firms):**
|
||||
> * "Designed and engineered a high-concurrency Node.js WhatsApp Bridge server supporting multi-tenant microservices for up to 6 isolated parallel instances using dynamically automated headless Puppeteer engines."
|
||||
> * "Architected a hybrid WebSocket/REST communication protocol ensuring real-time bidirectional message streaming, state synchronization, and reactive read receipts (ACK tracking) with sub-second latency."
|
||||
> * "Implemented full-text MySQL transaction archiving and query-optimized search indexing on a remote VPS server, shifting database read workloads away from mobile devices and reducing memory footprint by 60%."
|
||||
> * "Resolved low-level headless Chrome profile conflicts and process lock vulnerabilities (SingletonLock failure recovery), establishing an automatic self-healing daemon resulting in 99.9% application uptime."
|
||||
> * "Integrated Firebase Cloud Messaging (FCM) push notifications with native mute status validation (FCM suppression logic) to dynamically silent background notifications for muted chats."
|
||||
|
||||
---
|
||||
<div dir="rtl" align="right">
|
||||
هذا الدليل الفني يوثق عملك الفريد كـ **Solutions / Senior Systems Architect**، ويظهر للجميع البناء السحابي فائق الجودة الذي صنعته! 🚀🍏📲
|
||||
</div>
|
||||