Compare commits

...

28 Commits

Author SHA1 Message Date
Hamza-Ayed
f3daf4f3d8 chore: comment out SceneDelegate implementation and add devtools_options configuration file 2026-06-20 17:01:05 +03:00
Hamza-Ayed
9f43eaf8ef Sync update: 2026-06-20 16:53:51 2026-06-20 16:53:51 +03:00
Hamza-Ayed
10d4651965 Sync update: 2026-05-22 19:31:19 2026-05-22 19:31:19 +03:00
Hamza-Ayed
22f1bba6ac Sync update: 2026-05-19 23:27:14 2026-05-19 23:27:14 +03:00
Hamza-Ayed
1eec712c58 Sync update: 2026-05-18 21:13:35 2026-05-18 21:13:35 +03:00
Hamza-Ayed
340a22fffa docs: add comprehensive server architecture and api documentation 2026-05-18 21:13:13 +03:00
Hamza-Ayed
a7d7eaec9d feat: add full-screen image viewer and reactive ack icons 2026-05-18 20:55:12 +03:00
Hamza-Ayed
14d30f19bf fix: restore send_message and send_media WebSocket packet handlers in server.js 2026-05-18 20:34:36 +03:00
Hamza-Ayed
224bed32b5 fix: automate migration of session-whatsapp-bridge to session-slot-1 on startup 2026-05-18 20:26:06 +03:00
Hamza-Ayed
7a1997c329 fix: convert QR to base64 DataURL and migrate old session to Slot 1 dynamically 2026-05-18 20:21:17 +03:00
Hamza-Ayed
0ec9b2e3b2 feat: complete migration to 6-slot multi-tenant registry with MySQL message archiving 2026-05-18 20:15:53 +03:00
Hamza-Ayed
0498575e51 feat: ignore background and local notifications for muted chats 2026-05-18 19:53:59 +03:00
Hamza-Ayed
1d20d40fd8 feat: add /api/avatar endpoint and real-time poll vote event WS broadaster 2026-05-18 19:47:42 +03:00
Hamza-Ayed
39b028a85c feat: implement native interactive WhatsApp Polls REST API endpoint (/api/send-poll) 2026-05-18 19:44:09 +03:00
Hamza-Ayed
9490a2d628 fix: sanitize base64 strings in REST API by stripping data url prefixes and whitespaces 2026-05-18 19:42:06 +03:00
Hamza-Ayed
5717d7047e feat: expose REST API endpoints (/api/send, /api/send-media) for universal messaging proxy usage 2026-05-18 19:32:44 +03:00
Hamza-Ayed
123902a6b1 fix: resolve duplicate background notifications and format contact names in local notification titles 2026-05-18 18:53:24 +03:00
Hamza-Ayed
b3ef0b89f6 fix: resolve duplicate path and fs declarations and implement smart .env multi-path resolution 2026-05-18 18:32:31 +03:00
Hamza-Ayed
6882d6e952 security: support secure env-based firebase admin configurations and ignore sensitive keys in git 2026-05-18 18:27:08 +03:00
Hamza-Ayed
79ba52cb7d feat: implement Firebase Admin SDK push notification logic in server.js with fallback support 2026-05-18 18:22:27 +03:00
Hamza-Ayed
92d59b0f30 feat: trigger local notification directly from incoming WebSocket new_message event 2026-05-18 18:19:03 +03:00
Hamza-Ayed
cfc1fd0a8e fix: resolve iOS APNS token waiting loop in FirebaseService to guarantee successful FCM registration 2026-05-18 18:07:36 +03:00
Hamza-Ayed
60139d98c5 Sync update: 2026-05-18 17:57:04 2026-05-18 17:57:04 +03:00
Hamza-Ayed
cb4b423304 feat: enable macOS sandbox internet client network access and configure flutter_launcher_icons with premium generated app icon 2026-05-18 17:51:37 +03:00
Hamza-Ayed
a64725397e fix: downgrade record_platform_interface override to 1.2.0 to bypass startStream in record_linux 2026-05-18 17:39:28 +03:00
Hamza-Ayed
065855d596 fix: override record_platform_interface to 1.4.0 in pubspec.yaml to fix RecordLinux compilation mismatch 2026-05-18 17:37:04 +03:00
Hamza-Ayed
c1b149cc21 feat: implement real cross-platform voice recording utilizing record package with mic permission configuration 2026-05-18 17:32:31 +03:00
Hamza-Ayed
e18f4195b9 fix: resolve DarwinAudioError on iOS using temporary .mp3 extension and upgrade mock Voice Note base64 to valid silent MP3 2026-05-18 17:29:24 +03:00
68 changed files with 2942 additions and 1190 deletions

6
.gitignore vendored
View File

@@ -24,3 +24,9 @@ whatsapp_app/android/local.properties
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
# Sensitive Configurations
whatsapp_bridge/serviceAccountKey.json
whatsapp_bridge/fcm_token.json
whatsapp_bridge/.env
whatsapp_bridge/.env.*

View File

@@ -1,8 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:label="mywhatsapp" android:label="mywhatsapp"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/launcher_icon">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View 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:

View 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"

View File

@@ -75,7 +75,12 @@ PODS:
- nanopb/encode (= 2.30910.0) - nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0) - nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0) - nanopb/encode (2.30910.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- record_darwin (1.0.0):
- Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -91,6 +96,8 @@ DEPENDENCIES:
- flutter_contacts (from `.symlinks/plugins/flutter_contacts/ios`) - flutter_contacts (from `.symlinks/plugins/flutter_contacts/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/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`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -121,6 +128,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/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: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin: sqflite_darwin:
@@ -135,15 +146,17 @@ SPEC CHECKSUMS:
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 88950ba9485052891ebe26f6c43a52bb62248952 FirebaseMessaging: 88950ba9485052891ebe26f6c43a52bb62248952
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_contacts: 5383945387e7ca37cf963d4be57c21f2fc15ca9f flutter_contacts: 5383945387e7ca37cf963d4be57c21f2fc15ca9f
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
nanopb: 438bc412db1928dac798aa6fd75726007be04262 nanopb: 438bc412db1928dac798aa6fd75726007be04262
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
PODFILE CHECKSUM: 9de8a3281c07f7034a9eb8ce8a707f95c6003310 PODFILE CHECKSUM: 9de8a3281c07f7034a9eb8ce8a707f95c6003310

View File

@@ -67,6 +67,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
@@ -109,7 +110,6 @@
5FAD3FB76264405B9D466D11 /* Pods-RunnerTests.release.xcconfig */, 5FAD3FB76264405B9D466D11 /* Pods-RunnerTests.release.xcconfig */,
BE672457097ACB02A0172419 /* Pods-RunnerTests.profile.xcconfig */, BE672457097ACB02A0172419 /* Pods-RunnerTests.profile.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -149,6 +149,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C67DFB872FBB6A460051E88E /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@@ -478,6 +479,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 63CVT8G5P8; DEVELOPMENT_TEAM = 63CVT8G5P8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -548,7 +550,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -605,7 +607,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -661,6 +663,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 63CVT8G5P8; DEVELOPMENT_TEAM = 63CVT8G5P8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -684,6 +687,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 63CVT8G5P8; DEVELOPMENT_TEAM = 63CVT8G5P8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -26,8 +26,20 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -45,13 +57,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </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> </dict>
</plist> </plist>

View 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>

View File

@@ -1,6 +1,6 @@
import Flutter // import Flutter
import UIKit // import UIKit
//
class SceneDelegate: FlutterSceneDelegate { // class SceneDelegate: FlutterSceneDelegate {
//
} // }

View File

@@ -1,7 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:record/record.dart';
import 'package:path_provider/path_provider.dart';
import '../services/whatsapp_service.dart'; import '../services/whatsapp_service.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../models/message_model.dart'; import '../models/message_model.dart';
@@ -17,6 +21,13 @@ class ChatController extends GetxController {
final inputCtrl = TextEditingController(); final inputCtrl = TextEditingController();
final scrollCtrl = ScrollController(); final scrollCtrl = ScrollController();
final hasText = false.obs;
// Recording State
final audioRecord = AudioRecorder();
final isRecording = false.obs;
final recordDuration = 0.obs;
Timer? _recordTimer;
StreamSubscription? _eventSub; StreamSubscription? _eventSub;
@@ -32,6 +43,10 @@ class ChatController extends GetxController {
Get.find<ConversationsController>().clearUnreadCount(conversation.id); Get.find<ConversationsController>().clearUnreadCount(conversation.id);
} catch (_) {} } catch (_) {}
inputCtrl.addListener(() {
hasText.value = inputCtrl.text.trim().isNotEmpty;
});
loadMessages(); loadMessages();
markAsRead(); markAsRead();
@@ -45,6 +60,8 @@ class ChatController extends GetxController {
_svc.activeChatId.value = null; _svc.activeChatId.value = null;
} }
_eventSub?.cancel(); _eventSub?.cancel();
_recordTimer?.cancel();
audioRecord.dispose();
inputCtrl.dispose(); inputCtrl.dispose();
scrollCtrl.dispose(); scrollCtrl.dispose();
super.onClose(); super.onClose();
@@ -164,13 +181,23 @@ class ChatController extends GetxController {
case 'message_ack': case 'message_ack':
final messageId = event['messageId'] as String?; final messageId = event['messageId'] as String?;
final chatId = event['chatId'] 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 == null || messageId == null || ack == null) return;
if (chatId == conversation.id) { if (chatId == conversation.id) {
final index = messages.indexWhere((m) => m.id == messageId); final index = messages.indexWhere((m) => m.id == messageId);
if (index != -1) { 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; break;
@@ -228,4 +255,72 @@ class ChatController extends GetxController {
return DateFormat('MMMM d, yyyy').format(dt); 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');
}
}
} }

View File

@@ -166,6 +166,7 @@ class ConversationsController extends GetxController {
timestamp: msgData['timestamp'] ?? 0, timestamp: msgData['timestamp'] ?? 0,
fromMe: msgData['fromMe'] ?? false, fromMe: msgData['fromMe'] ?? false,
hasMedia: msgData['hasMedia'] ?? false, hasMedia: msgData['hasMedia'] ?? false,
ack: msgData['ack'] ?? 0,
); );
// Find existing conversation and update it // Find existing conversation and update it

View File

@@ -11,18 +11,13 @@ import 'theme/app_theme.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase (Requires flutterfire configure) // Initialize Firebase
try { try {
await Firebase.initializeApp(); await Firebase.initializeApp();
} catch (e) { } catch (e) {
print('Firebase initialization error: $e'); print('Firebase initialization error: $e');
} }
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
));
// Register services before app starts // Register services before app starts
Get.put(ContactsService(), permanent: true); Get.put(ContactsService(), permanent: true);
Get.put(WhatsAppService(), permanent: true); Get.put(WhatsAppService(), permanent: true);
@@ -41,9 +36,12 @@ class WhatsAppApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GetMaterialApp( return GetMaterialApp(
title: 'WhatsApp App', title: 'WhatsApp',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.dark, // Follow device theme — no forced dark/light
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
home: const ConversationsScreen(), home: const ConversationsScreen(),
defaultTransition: Transition.cupertino, defaultTransition: Transition.cupertino,
); );

View File

@@ -3,12 +3,14 @@ class LastMessageModel {
final int timestamp; final int timestamp;
final bool fromMe; final bool fromMe;
final bool hasMedia; final bool hasMedia;
final int ack;
LastMessageModel({ LastMessageModel({
required this.body, required this.body,
required this.timestamp, required this.timestamp,
required this.fromMe, required this.fromMe,
required this.hasMedia, required this.hasMedia,
required this.ack,
}); });
factory LastMessageModel.fromJson(Map<String, dynamic> json) { factory LastMessageModel.fromJson(Map<String, dynamic> json) {
@@ -17,6 +19,7 @@ class LastMessageModel {
timestamp: json['timestamp'] ?? 0, timestamp: json['timestamp'] ?? 0,
fromMe: json['fromMe'] ?? false, fromMe: json['fromMe'] ?? false,
hasMedia: json['hasMedia'] ?? false, hasMedia: json['hasMedia'] ?? false,
ack: json['ack'] ?? 0,
); );
} }
@@ -26,6 +29,7 @@ class LastMessageModel {
'timestamp': timestamp, 'timestamp': timestamp,
'fromMe': fromMe, 'fromMe': fromMe,
'hasMedia': hasMedia, 'hasMedia': hasMedia,
'ack': ack,
}; };
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../controllers/chat_controller.dart'; import '../controllers/chat_controller.dart';
@@ -19,69 +20,157 @@ class ChatScreen extends StatelessWidget {
ChatController(conversation: conversation), ChatController(conversation: conversation),
tag: conversation.id, tag: conversation.id,
); );
final isDark = AppTheme.isDark(context);
return Scaffold( return Scaffold(
backgroundColor: AppTheme.background, backgroundColor: AppTheme.chatBackground(context),
appBar: _buildAppBar(conversation), appBar: _buildAppBar(context, conversation, ctrl),
body: Column( body: Column(
children: [ children: [
Expanded(child: _buildMessageList(ctrl)), Expanded(child: _buildMessageList(context, ctrl)),
_buildInputBar(ctrl), _buildInputBar(context, ctrl),
], ],
), ),
); );
} }
AppBar _buildAppBar(ConversationModel chat) => AppBar( PreferredSizeWidget _buildAppBar(
backgroundColor: AppTheme.surface, BuildContext context,
leadingWidth: 32, ConversationModel chat,
title: Row( ChatController ctrl,
children: [ ) {
_avatar(chat, radius: 18), final isDark = AppTheme.isDark(context);
const SizedBox(width: 10), return AppBar(
Expanded( backgroundColor: AppTheme.surface(context),
child: Column( leadingWidth: 36,
crossAxisAlignment: CrossAxisAlignment.start, titleSpacing: 0,
children: [ title: InkWell(
Text( onTap: () {}, // Future: open contact info
chat.name, child: Row(
style: const TextStyle( children: [
color: AppTheme.textPrimary, _buildAppBarAvatar(context, chat),
fontSize: 16, const SizedBox(width: 10),
fontWeight: FontWeight.w600, Expanded(
), child: Column(
overflow: TextOverflow.ellipsis, 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(() { return Obx(() {
if (ctrl.isLoading.value) { if (ctrl.isLoading.value && ctrl.messages.isEmpty) {
return const Center( return Center(
child: CircularProgressIndicator(color: AppTheme.primary), child: CircularProgressIndicator(color: AppTheme.primary),
); );
} }
@@ -92,11 +181,16 @@ class ChatScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 12),
Text( Text(
'No messages yet', '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( return ListView.builder(
controller: ctrl.scrollCtrl, controller: ctrl.scrollCtrl,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
itemCount: items.length, itemCount: items.length,
itemBuilder: (_, i) { itemBuilder: (_, i) {
final item = items[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); return MessageBubble(message: item as MessageModel);
}, },
); );
}); });
} }
Widget _buildDateSeparator(String label) => Center( Widget _buildDateSeparator(BuildContext context, String label) {
child: Container( final isDark = AppTheme.isDark(context);
margin: const EdgeInsets.symmetric(vertical: 8), return Center(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Container(
decoration: BoxDecoration( margin: const EdgeInsets.symmetric(vertical: 8),
color: AppTheme.surfaceLight, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), color: isDark
child: Text( ? const Color(0xff1d2b33)
label, : const Color(0xffd1f4cc),
style: const TextStyle( borderRadius: BorderRadius.circular(8),
color: AppTheme.textSecondary, boxShadow: [
fontSize: 12, BoxShadow(
fontWeight: FontWeight.w500, 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( Widget _buildInputBar(BuildContext context, ChatController ctrl) {
color: AppTheme.surface, final isDark = AppTheme.isDark(context);
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), final barBg = isDark ? AppTheme.darkBackground : AppTheme.lightBackground;
child: SafeArea( final inputBg = isDark ? AppTheme.darkSurfaceLight : AppTheme.lightSurfaceLight;
child: Row(
children: [ return Container(
// Attachment button color: barBg,
IconButton( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
icon: const Icon(Icons.add, color: AppTheme.primary, size: 28), child: SafeArea(
onPressed: () => _showAttachmentSheet(ctrl), child: Obx(() {
), // ── Recording UI ─────────────────────────────────────────────────
// Input if (ctrl.isRecording.value) {
Expanded( return Row(
child: TextField( children: [
controller: ctrl.inputCtrl, const SizedBox(width: 12),
style: const TextStyle(color: AppTheme.textPrimary), const Icon(Icons.fiber_manual_record,
maxLines: 5, color: Colors.red, size: 14),
minLines: 1, const SizedBox(width: 6),
textCapitalization: TextCapitalization.sentences, const Text(
decoration: InputDecoration( 'Recording...',
hintText: 'Message', style: TextStyle(
hintStyle: const TextStyle(color: AppTheme.textSecondary), color: Colors.red,
filled: true, fontWeight: FontWeight.bold,
fillColor: AppTheme.surfaceLight, fontSize: 14),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10,
), ),
border: OutlineInputBorder( const SizedBox(width: 12),
borderRadius: BorderRadius.circular(24), Text(
borderSide: BorderSide.none, '${(ctrl.recordDuration.value ~/ 60).toString().padLeft(2, '0')}:${(ctrl.recordDuration.value % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: AppTheme.textPrimary(context),
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()]),
), ),
), const Spacer(),
onSubmitted: (_) => ctrl.sendMessage(), TextButton.icon(
), icon: const Icon(Icons.delete,
), color: Colors.redAccent, size: 18),
const SizedBox(width: 8), label: const Text('Cancel',
// Send button style: TextStyle(color: Colors.redAccent)),
Obx(() => GestureDetector( onPressed: ctrl.cancelRecording,
onTap: ctrl.sendMessage, ),
child: AnimatedContainer( const SizedBox(width: 8),
duration: const Duration(milliseconds: 200), GestureDetector(
width: 48, onTap: ctrl.stopAndSendRecording,
height: 48, child: Container(
decoration: const BoxDecoration( width: 44,
color: AppTheme.primary, height: 44,
shape: BoxShape.circle, decoration: const BoxDecoration(
), color: AppTheme.primary,
child: ctrl.isSending.value shape: BoxShape.circle,
? const Padding( ),
padding: EdgeInsets.all(12), child: const Icon(Icons.check,
child: CircularProgressIndicator( color: Colors.white, size: 20),
strokeWidth: 2, ),
color: Colors.white, ),
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),
), ),
) Expanded(
: const Icon(Icons.send, color: Colors.white, size: 20), 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( Get.bottomSheet(
Container( Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
color: AppTheme.surface, color: isDark ? AppTheme.darkSurface : Colors.white,
borderRadius: BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20), topLeft: Radius.circular(20),
topRight: 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 40, width: 36,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.textSecondary.withOpacity(0.3), color: AppTheme.textSecondary(context).withOpacity(0.3),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
const Text(
'Send Media Attachment',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildAttachmentItem( _buildAttachmentItem(
icon: Icons.camera_alt, context: context,
color: Colors.green, icon: Icons.photo_library_rounded,
label: 'Camera', color: const Color(0xff7c4dff),
onTap: () {
Get.back();
_pickAndSendImage(ctrl, ImageSource.camera);
},
),
_buildAttachmentItem(
icon: Icons.photo_library,
color: Colors.purple,
label: 'Gallery', label: 'Gallery',
onTap: () { onTap: () {
Get.back(); Get.back();
@@ -252,23 +446,34 @@ class ChatScreen extends StatelessWidget {
}, },
), ),
_buildAttachmentItem( _buildAttachmentItem(
icon: Icons.mic, context: context,
color: Colors.orange, icon: Icons.camera_alt_rounded,
label: 'Voice Note', color: const Color(0xffff4081),
label: 'Camera',
onTap: () { onTap: () {
Get.back(); Get.back();
// Real WhatsApp voice note Ogg/Opus snippet base64 _pickAndSendImage(ctrl, ImageSource.camera);
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, _buildAttachmentItem(
'audio/ogg', context: context,
'voice_note.ogg', 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),
], ],
), ),
), ),
@@ -299,7 +504,6 @@ class ChatScreen extends StatelessWidget {
base64String, base64String,
mimetype, mimetype,
image.name, image.name,
caption: '📸 Photo sent via Mywhatsapp!',
); );
} catch (e) { } catch (e) {
Get.snackbar( Get.snackbar(
@@ -312,48 +516,34 @@ class ChatScreen extends StatelessWidget {
} }
Widget _buildAttachmentItem({ Widget _buildAttachmentItem({
required BuildContext context,
required IconData icon, required IconData icon,
required Color color, required Color color,
required String label, required String label,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
final isDark = AppTheme.isDark(context);
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Column( child: Column(
children: [ children: [
CircleAvatar( Container(
radius: 28, width: 54,
backgroundColor: color.withOpacity(0.15), height: 54,
child: Icon(icon, color: color, size: 28), decoration: BoxDecoration(
color: color.withOpacity(0.12),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 26),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
label, 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,
),
),
);
}
} }

View File

@@ -15,134 +15,218 @@ class ConversationsScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final svc = Get.find<WhatsAppService>(); final svc = Get.find<WhatsAppService>();
final ctrl = Get.put(ConversationsController()); final ctrl = Get.put(ConversationsController());
final isDark = AppTheme.isDark(context);
return Scaffold( return Scaffold(
backgroundColor: AppTheme.background, backgroundColor: AppTheme.background(context),
appBar: _buildAppBar(ctrl), appBar: _buildAppBar(context, ctrl),
body: Obx(() { body: Obx(() {
// Not connected // Not connected
if (svc.status.value == WsStatus.disconnected || if (svc.status.value == WsStatus.disconnected ||
svc.status.value == WsStatus.connecting) { svc.status.value == WsStatus.connecting) {
return _buildConnecting(); return _buildConnecting(context);
} }
// QR Code needed // QR Code needed
if (svc.qrData.value != null) { if (svc.qrData.value != null) {
return const QrView(); return const QrView();
} }
// Loading conversations // Loading conversations
if (ctrl.isLoading.value) { if (ctrl.isLoading.value && ctrl.conversations.isEmpty) {
return const Center( return Center(
child: CircularProgressIndicator(color: AppTheme.primary), child: CircularProgressIndicator(color: AppTheme.primary),
); );
} }
// Error // Error
if (ctrl.errorMessage.value != null) { if (ctrl.errorMessage.value != null && ctrl.conversations.isEmpty) {
return _buildError(ctrl); return _buildError(context, ctrl);
} }
// Empty // Empty
if (ctrl.conversations.isEmpty) { if (ctrl.conversations.isEmpty) {
return _buildEmpty(); return _buildEmpty(context);
} }
// List // List
return _buildList(ctrl); return _buildList(context, ctrl);
}), }),
); );
} }
AppBar _buildAppBar(ConversationsController ctrl) { PreferredSizeWidget _buildAppBar(
BuildContext context, ConversationsController ctrl) {
final searching = false.obs; final searching = false.obs;
final isDark = AppTheme.isDark(context);
return AppBar( return AppBar(
backgroundColor: AppTheme.surface, backgroundColor: AppTheme.surface(context),
elevation: 0,
title: Obx(() => searching.value title: Obx(() => searching.value
? TextField( ? TextField(
autofocus: true, autofocus: true,
style: const TextStyle(color: AppTheme.textPrimary), style: TextStyle(color: isDark ? Colors.white : Colors.white),
decoration: const InputDecoration( cursorColor: Colors.white,
decoration: InputDecoration(
hintText: 'Search...', hintText: 'Search...',
border: InputBorder.none, border: InputBorder.none,
hintStyle: TextStyle(color: AppTheme.textSecondary), hintStyle: TextStyle(
color: Colors.white.withOpacity(0.7)),
), ),
onChanged: ctrl.search, onChanged: ctrl.search,
) )
: const Text('WhatsApp', style: TextStyle(color: AppTheme.textPrimary))), : const Text(
'WhatsApp',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
)),
actions: [ actions: [
Obx(() => IconButton( 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( icon: Icon(
searching.value ? Icons.close : Icons.search, Icons.more_vert,
color: AppTheme.iconColor, 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( Widget _buildConnecting(BuildContext context) {
child: Column( final svc = Get.find<WhatsAppService>();
mainAxisSize: MainAxisSize.min, return Center(
children: [ child: Column(
const CircularProgressIndicator(color: AppTheme.primary), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 16), children: [
Text( CircularProgressIndicator(color: AppTheme.primary),
'Connecting to server...', const SizedBox(height: 16),
style: TextStyle(color: AppTheme.textSecondary), 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( Widget _buildError(
child: Column( BuildContext context, ConversationsController ctrl) =>
mainAxisSize: MainAxisSize.min, Center(
children: [ child: Padding(
const Icon(Icons.error_outline, color: Colors.redAccent, size: 48), padding: const EdgeInsets.all(24),
const SizedBox(height: 12), child: Column(
Text( mainAxisSize: MainAxisSize.min,
ctrl.errorMessage.value ?? 'Error', children: [
style: const TextStyle(color: AppTheme.textSecondary), const Icon(Icons.error_outline,
textAlign: TextAlign.center, 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, Widget _buildEmpty(BuildContext context) => Center(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), child: Column(
child: const Text('Retry'), 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( Widget _buildList(
child: Text( BuildContext context, ConversationsController ctrl) {
'No conversations found',
style: TextStyle(color: AppTheme.textSecondary),
),
);
Widget _buildList(ConversationsController ctrl) {
return RefreshIndicator( return RefreshIndicator(
color: AppTheme.primary, color: AppTheme.primary,
backgroundColor: AppTheme.surface, backgroundColor: AppTheme.isDark(context)
? AppTheme.darkSurface
: Colors.white,
onRefresh: ctrl.loadConversations, onRefresh: ctrl.loadConversations,
child: ListView.builder( child: ListView.separated(
itemCount: ctrl.conversations.length, 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) { itemBuilder: (_, i) {
final chat = ctrl.conversations[i]; final chat = ctrl.conversations[i];
return ConversationTile( return ConversationTile(

View File

@@ -20,40 +20,41 @@ class QrView extends StatelessWidget {
const Icon(Icons.qr_code_scanner, const Icon(Icons.qr_code_scanner,
color: AppTheme.primary, size: 64), color: AppTheme.primary, size: 64),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'Link with your phone', 'Link with your phone',
style: TextStyle( style: TextStyle(
color: AppTheme.textPrimary, color: AppTheme.textPrimary(context),
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.surfaceLight, color: AppTheme.surfaceLight(context),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'1. Open WhatsApp on your phone', '1. Open WhatsApp on your phone',
style: style: TextStyle(
TextStyle(color: AppTheme.textSecondary, fontSize: 14), color: AppTheme.textSecondary(context), fontSize: 14),
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'2. Tap Menu (⋮ or ⚙️) → Linked Devices', '2. Tap Menu (⋮ or ⚙️) → Linked Devices',
style: style: TextStyle(
TextStyle(color: AppTheme.textSecondary, fontSize: 14), color: AppTheme.textSecondary(context), fontSize: 14),
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'3. Tap "Link a Device" and scan this QR code', '3. Tap "Link a Device" and scan this QR code',
style: style: TextStyle(
TextStyle(color: AppTheme.textSecondary, fontSize: 14), color: AppTheme.textSecondary(context), fontSize: 14),
), ),
], ],
), ),
@@ -66,13 +67,21 @@ class QrView extends StatelessWidget {
} }
try { try {
final base64Image = qr.contains(',') ? qr.split(',')[1] : qr; final base64Image =
qr.contains(',') ? qr.split(',')[1] : qr;
final bytes = base64Decode(base64Image); final bytes = base64Decode(base64Image);
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 12,
offset: const Offset(0, 4),
)
],
), ),
child: Image.memory( child: Image.memory(
bytes, bytes,
@@ -89,7 +98,8 @@ class QrView extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Failed to render QR Code: $e', '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), const SizedBox(height: 16),
Text( Text(
'Waiting for QR Code from WhatsApp...', 'Waiting for QR Code from WhatsApp...',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), style: TextStyle(
color: AppTheme.textSecondary(context), fontSize: 12),
), ),
], ],
), ),

View File

@@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/conversations_controller.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../screens/chat_screen.dart'; import '../screens/chat_screen.dart';
import 'whatsapp_service.dart'; import 'whatsapp_service.dart';
@@ -76,9 +79,27 @@ class FirebaseService extends GetxService {
void _setupFcmTokenRegistration() async { void _setupFcmTokenRegistration() async {
try { 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(); final token = await _messaging.getToken();
if (token != null) { 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); _registerTokenOnServer(token);
} }
} catch (e) { } 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) { void _onNotificationTap(NotificationResponse response) {
if (response.payload != null) { if (response.payload != null) {
final data = jsonDecode(response.payload!); final data = jsonDecode(response.payload!);
@@ -146,7 +215,6 @@ class FirebaseService extends GetxService {
final name = data['name'] ?? 'Chat'; final name = data['name'] ?? 'Chat';
if (chatId != null) { if (chatId != null) {
// Mock a conversation model to navigate to ChatScreen
final dummyChat = ConversationModel( final dummyChat = ConversationModel(
id: chatId, id: chatId,
name: name, name: name,
@@ -157,7 +225,7 @@ class FirebaseService extends GetxService {
isMuted: false, isMuted: false,
); );
Get.to(() => ChatScreen(conversation: dummyChat)); Get.to(() => ChatScreen(conversation: dummyChat), preventDuplicates: false);
} }
} }
} }

View File

@@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import 'contacts_service.dart';
import 'firebase_service.dart';
enum WsStatus { disconnected, connecting, connected, waReady } enum WsStatus { disconnected, connecting, connected, waReady }
@@ -93,7 +95,34 @@ class WhatsAppService extends GetxService {
// Push events // Push events
switch (type) { 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': case 'qr':
qrData.value = data['qr']; qrData.value = data['qr'];
isWaReady.value = false; isWaReady.value = false;
if (status.value == WsStatus.waReady) { if (status.value == WsStatus.waReady) {
@@ -153,12 +182,14 @@ class WhatsAppService extends GetxService {
void _scheduleReconnect() { void _scheduleReconnect() {
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
if (_reconnectCount >= AppConfig.maxReconnectAttempts) {
print('[WS] Max reconnect attempts reached');
return;
}
_reconnectCount++; _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 ───────────────────────────────────────────────────── // ── Request/Response ─────────────────────────────────────────────────────

View File

@@ -1,39 +1,56 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppTheme { class AppTheme {
// Dark WhatsApp Palette // ── WhatsApp Dark Palette ────────────────────────────────────────────────
static const Color background = Color(0xff111b21); static const Color darkBackground = Color(0xff111b21);
static const Color surface = Color(0xff1f2c34); static const Color darkSurface = Color(0xff1f2c34);
static const Color surfaceLight = Color(0xff2a3942); static const Color darkSurfaceLight = Color(0xff2a3942);
static const Color primary = Color(0xff00a884); static const Color darkOutgoingMsg = Color(0xff005c4b);
static const Color primaryDark = Color(0xff005c4b); static const Color darkIncomingMsg = Color(0xff1f2c34);
static const Color darkTextPrimary = Color(0xffe9edef);
static const Color darkTextSecondary= Color(0xff8696a0);
static const Color outgoingMsg = Color(0xff005c4b); // ── WhatsApp Light Palette ───────────────────────────────────────────────
static const Color incomingMsg = Color(0xff1f2c34); 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
static const Color textPrimary = Color(0xffe9edef); // ── Shared Colors ────────────────────────────────────────────────────────
static const Color textSecondary = Color(0xff8696a0); static const Color primary = Color(0xff25d366); // WhatsApp green
static const Color iconColor = Color(0xff8696a0); 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 { static ThemeData get dark {
return ThemeData.dark().copyWith( return ThemeData(
scaffoldBackgroundColor: background, brightness: Brightness.dark,
primaryColor: primary, scaffoldBackgroundColor: darkBackground,
primaryColor: teal,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: primary, primary: primary,
background: background, secondary: primaryDark,
surface: surface, surface: darkSurface,
background: darkBackground,
), ),
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: surface, backgroundColor: darkSurface,
foregroundColor: darkTextPrimary,
elevation: 0, elevation: 0,
iconTheme: IconThemeData(color: iconColor), iconTheme: IconThemeData(color: darkTextSecondary),
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
color: textPrimary, color: darkTextPrimary,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
dividerColor: darkSurfaceLight,
textSelectionTheme: const TextSelectionThemeData( textSelectionTheme: const TextSelectionThemeData(
cursorColor: primary, cursorColor: primary,
selectionColor: primaryDark, 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;
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
@@ -17,117 +18,217 @@ class ConversationTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lastMsg = conversation.lastMessage; final lastMsg = conversation.lastMessage;
final hasUnread = conversation.unreadCount > 0; final hasUnread = conversation.unreadCount > 0;
final isDark = AppTheme.isDark(context);
return ListTile( return InkWell(
onTap: onTap, onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Container(
leading: _buildAvatar(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
title: Row(
children: [
Expanded(
child: Text(
conversation.name,
style: const TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
_formatTime(conversation.timestamp),
style: TextStyle(
color: hasUnread ? AppTheme.primary : AppTheme.textSecondary,
fontSize: 12,
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row( child: Row(
children: [ children: [
if (lastMsg != null && lastMsg.fromMe) ...[ // ── Avatar ──────────────────────────────────────────────────────
const Icon(Icons.done_all, size: 16, color: AppTheme.primary), // Or proper ACK double tick _buildAvatar(context, conversation),
const SizedBox(width: 4), const SizedBox(width: 12),
],
// ── Content ─────────────────────────────────────────────────────
Expanded( Expanded(
child: Text( child: Column(
_getSubtitleText(lastMsg), crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
color: AppTheme.textSecondary, // Name row + time
fontSize: 14, Row(
), children: [
maxLines: 1, Expanded(
overflow: TextOverflow.ellipsis, 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() { // ── Avatar builder (cached network image + fallback initials) ─────────────
if (conversation.avatar != null) { 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( return CircleAvatar(
radius: 26, radius: 28,
backgroundImage: NetworkImage(conversation.avatar!), backgroundColor: fallbackBg,
backgroundColor: AppTheme.surfaceLight, 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( return CircleAvatar(
radius: 26, radius: 28,
backgroundColor: AppTheme.primaryDark, backgroundColor: fallbackBg,
child: Text( child: _initialsAvatar(c.name, fallbackBg),
conversation.name.isNotEmpty ? conversation.name[0].toUpperCase() : '?', );
style: const TextStyle( }
color: Colors.white,
fontSize: 18, Widget _initialsAvatar(String name, Color bg) {
fontWeight: FontWeight.bold, 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 == null) return '';
if (lastMsg.hasMedia) { if (lastMsg.hasMedia) {
return '📷 Photo'; // or other media indicator return '📷 Photo';
} }
return lastMsg.body; return lastMsg.body;
} }
// ── Time formatter ────────────────────────────────────────────────────────
String _formatTime(int timestamp) { String _formatTime(int timestamp) {
if (timestamp == 0) return ''; if (timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
@@ -137,13 +238,13 @@ class ConversationTile extends StatelessWidget {
final msgDate = DateTime(dt.year, dt.month, dt.day); final msgDate = DateTime(dt.year, dt.month, dt.day);
if (msgDate == today) { if (msgDate == today) {
return DateFormat('hh:mm a').format(dt); return DateFormat('h:mm a').format(dt);
} else if (msgDate == yesterday) { } else if (msgDate == yesterday) {
return 'Yesterday'; return 'Yesterday';
} else if (now.difference(dt).inDays < 7) { } else if (now.difference(dt).inDays < 7) {
return DateFormat('EEEE').format(dt); // e.g. "Monday" return DateFormat('EEEE').format(dt);
} else { } else {
return DateFormat('MM/dd/yy').format(dt); return DateFormat('dd/MM/yy').format(dt);
} }
} }
} }

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -8,6 +10,41 @@ import '../models/message_model.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../services/whatsapp_service.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 { class MessageBubble extends StatelessWidget {
final MessageModel message; final MessageModel message;
@@ -15,114 +52,171 @@ class MessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMe = message.fromMe; final isMe = message.fromMe;
final align = isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start; final isDark = AppTheme.isDark(context);
final bg = isMe ? AppTheme.outgoingMsg : AppTheme.incomingMsg;
final bg = isMe
? AppTheme.outgoingMsg(context)
: AppTheme.incomingMsg(context);
final radius = isMe final radius = isMe
? const BorderRadius.only( ? const BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
topRight: Radius.circular(0), topRight: Radius.circular(4),
bottomLeft: Radius.circular(12), bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12), bottomRight: Radius.circular(12),
) )
: const BorderRadius.only( : const BorderRadius.only(
topLeft: Radius.circular(0), topLeft: Radius.circular(4),
topRight: Radius.circular(12), topRight: Radius.circular(12),
bottomLeft: Radius.circular(12), bottomLeft: Radius.circular(12),
bottomRight: 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( 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( child: Column(
crossAxisAlignment: align, crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [ children: [
Container( // Tail + bubble
constraints: BoxConstraints( Stack(
maxWidth: MediaQuery.of(context).size.width * 0.75, children: [
), // Message bubble
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), Container(
decoration: BoxDecoration( constraints: BoxConstraints(
color: bg, maxWidth: MediaQuery.of(context).size.width * 0.78,
borderRadius: radius, ),
), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Column( decoration: decoration,
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
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(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible( // Sender name in group chats (incoming only)
child: Text( if (!isMe && message.author != null) ...[
message.body, Text(
message.author!,
style: const TextStyle( style: const TextStyle(
color: AppTheme.textPrimary, color: AppTheme.primaryDark,
fontSize: 15, fontSize: 12.5,
fontWeight: FontWeight.bold,
), ),
), ),
), const SizedBox(height: 2),
const SizedBox(width: 8), ],
Padding(
padding: const EdgeInsets.only(top: 4), // Media
child: Row( if (message.hasMedia) ...[
mainAxisSize: MainAxisSize.min, InteractiveMediaWidget(message: message),
children: [ const SizedBox(height: 4),
Text( ],
_formatTime(message.timestamp),
style: const TextStyle( // Text + time + ACK row
color: AppTheme.textSecondary, _buildTextTimeRow(context, isMe),
fontSize: 10,
),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildAckIcon(message.ack),
],
],
),
),
], ],
), ),
], ),
), ],
), ),
], ],
), ),
); );
} }
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) { switch (ack) {
case 1: // Pending case -1:
return const Icon(Icons.access_time, size: 13, color: AppTheme.textSecondary); case 0:
case 2: // Sent // Pending — clock
return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary); return Icon(Icons.access_time_rounded,
case 3: // Delivered size: 14, color: AppTheme.textSecondary(context));
return const Icon(Icons.done_all, size: 15, color: AppTheme.textSecondary); case 1:
case 4: // Read // Sent — single grey tick
return const Icon(Icons.done_all, size: 15, color: Colors.blue); 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: default:
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -135,6 +229,7 @@ class MessageBubble extends StatelessWidget {
} }
} }
// ─── Interactive Media Widget ─────────────────────────────────────────────
class InteractiveMediaWidget extends StatefulWidget { class InteractiveMediaWidget extends StatefulWidget {
final MessageModel message; final MessageModel message;
@@ -143,6 +238,7 @@ class InteractiveMediaWidget extends StatefulWidget {
@override @override
State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState(); State<InteractiveMediaWidget> createState() => _InteractiveMediaWidgetState();
} }
class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> { class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
final WhatsAppService _svc = Get.find<WhatsAppService>(); final WhatsAppService _svc = Get.find<WhatsAppService>();
bool _isLoading = false; bool _isLoading = false;
@@ -210,7 +306,16 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
await _player.pause(); await _player.pause();
} else { } else {
final bytes = base64Decode(base64Data); 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) { } catch (e) {
print('[AUDIO PLAYBACK ERROR] $e'); print('[AUDIO PLAYBACK ERROR] $e');
@@ -230,7 +335,7 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
final cachedMedia = _svc.mediaCache[widget.message.id]; final cachedMedia = _svc.mediaCache[widget.message.id];
if (cachedMedia != null) { if (cachedMedia != null) {
return _buildDownloadedMedia(cachedMedia); return _buildDownloadedMedia(context, cachedMedia);
} }
if (_isLoading) { if (_isLoading) {
@@ -239,43 +344,66 @@ class _InteractiveMediaWidgetState extends State<InteractiveMediaWidget> {
width: 140, width: 140,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15), color: Colors.black.withOpacity(0.12),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const SizedBox( child: const SizedBox(
width: 24, width: 24,
height: 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( return GestureDetector(
onTap: _downloadMedia, onTap: _downloadMedia,
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15), color: Colors.black.withOpacity(0.10),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(_getIcon(), color: AppTheme.textSecondary, size: 32), Container(
const SizedBox(width: 12), 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( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_getLabel(), _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 SizedBox(height: 2),
const Text( Row(
'Tap to download', children: [
style: TextStyle(color: AppTheme.textSecondary, fontSize: 10), 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); final bytes = base64Decode(base64Data);
// ── Image / Sticker ────────────────────────────────────────────────────
if (widget.message.type == "image" || widget.message.type == "sticker") { if (widget.message.type == "image" || widget.message.type == "sticker") {
return ClipRRect( final heroTag = 'img_${widget.message.id}';
borderRadius: BorderRadius.circular(8), return GestureDetector(
child: ConstrainedBox( onTap: () {
constraints: const BoxConstraints(maxHeight: 250), Navigator.of(context).push(
child: Image.memory( PageRouteBuilder(
bytes, opaque: false,
fit: BoxFit.cover, barrierColor: Colors.black,
width: double.infinity, 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") { 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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( CircleAvatar(
icon: Icon( radius: 18,
_isPlaying ? Icons.pause : Icons.play_arrow, backgroundColor: AppTheme.primary,
color: AppTheme.primary, child: IconButton(
size: 24, padding: EdgeInsets.zero,
), icon: Icon(
onPressed: () => _toggleAudioPlayback(base64Data), _isPlaying
), ? Icons.pause_rounded
Expanded( : Icons.play_arrow_rounded,
child: Padding( color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8), size: 20,
child: LinearProgressIndicator(
value: _audioProgress,
backgroundColor: AppTheme.surfaceLight,
color: AppTheme.primary,
), ),
onPressed: () => _toggleAudioPlayback(base64Data),
), ),
), ),
Text( const SizedBox(width: 8),
'0:${_audioCurrentSeconds.toString().padLeft(2, '0')}', Expanded(
style: const TextStyle( child: Column(
color: AppTheme.textPrimary, crossAxisAlignment: CrossAxisAlignment.start,
fontFamily: 'monospace', children: [
fontSize: 11, 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( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15), color: Colors.black.withOpacity(0.10),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.check_circle_outline, color: AppTheme.primary, size: 32), Container(
const SizedBox(width: 12), 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( Text(
_getLabel(), _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() { IconData _getIcon() {
switch (widget.message.type) { switch (widget.message.type) {
case "image": return Icons.photo_outlined; case "image":
case "video": return Icons.videocam_outlined; return Icons.photo_camera_rounded;
case "audio": return Icons.audiotrack_outlined; case "video":
case "sticker": return Icons.emoji_emotions_outlined; return Icons.videocam_rounded;
default: return Icons.insert_drive_file_outlined; case "audio":
return Icons.mic_rounded;
case "sticker":
return Icons.emoji_emotions_rounded;
default:
return Icons.insert_drive_file_rounded;
} }
} }
String _getLabel() { String _getLabel() {
switch (widget.message.type) { switch (widget.message.type) {
case "image": return "Image Attachment"; case "image":
case "video": return "Video Attachment"; return "Photo";
case "audio": return "Audio / Voice Note"; case "video":
case "sticker": return "Sticker Attachment"; return "Video";
default: return "File Attachment"; case "audio":
return "Voice note";
case "sticker":
return "Sticker";
default:
return "File";
} }
} }
} }

View File

@@ -8,6 +8,7 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h> #include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <record_linux/record_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); 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);
} }

View File

@@ -5,10 +5,10 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux audioplayers_linux
file_selector_linux file_selector_linux
record_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -10,6 +10,8 @@ import file_selector_macos
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
import path_provider_foundation
import record_darwin
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
@@ -19,6 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@@ -1,68 +1,68 @@
{ {
"images" : [ "info": {
{ "version": 1,
"size" : "16x16", "author": "xcode"
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
}, },
{ "images": [
"size" : "16x16", {
"idiom" : "mac", "size": "16x16",
"filename" : "app_icon_32.png", "idiom": "mac",
"scale" : "2x" "filename": "app_icon_16.png",
}, "scale": "1x"
{ },
"size" : "32x32", {
"idiom" : "mac", "size": "16x16",
"filename" : "app_icon_32.png", "idiom": "mac",
"scale" : "1x" "filename": "app_icon_32.png",
}, "scale": "2x"
{ },
"size" : "32x32", {
"idiom" : "mac", "size": "32x32",
"filename" : "app_icon_64.png", "idiom": "mac",
"scale" : "2x" "filename": "app_icon_32.png",
}, "scale": "1x"
{ },
"size" : "128x128", {
"idiom" : "mac", "size": "32x32",
"filename" : "app_icon_128.png", "idiom": "mac",
"scale" : "1x" "filename": "app_icon_64.png",
}, "scale": "2x"
{ },
"size" : "128x128", {
"idiom" : "mac", "size": "128x128",
"filename" : "app_icon_256.png", "idiom": "mac",
"scale" : "2x" "filename": "app_icon_128.png",
}, "scale": "1x"
{ },
"size" : "256x256", {
"idiom" : "mac", "size": "128x128",
"filename" : "app_icon_256.png", "idiom": "mac",
"scale" : "1x" "filename": "app_icon_256.png",
}, "scale": "2x"
{ },
"size" : "256x256", {
"idiom" : "mac", "size": "256x256",
"filename" : "app_icon_512.png", "idiom": "mac",
"scale" : "2x" "filename": "app_icon_256.png",
}, "scale": "1x"
{ },
"size" : "512x512", {
"idiom" : "mac", "size": "256x256",
"filename" : "app_icon_512.png", "idiom": "mac",
"scale" : "1x" "filename": "app_icon_512.png",
}, "scale": "2x"
{ },
"size" : "512x512", {
"idiom" : "mac", "size": "512x512",
"filename" : "app_icon_1024.png", "idiom": "mac",
"scale" : "2x" "filename": "app_icon_512.png",
} "scale": "1x"
], },
"info" : { {
"version" : 1, "size": "512x512",
"author" : "xcode" "idiom": "mac",
} "filename": "app_icon_1024.png",
"scale": "2x"
}
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -8,5 +8,7 @@
<true/> <true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<true/> <true/>
<key>com.apple.security.network.client</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -4,5 +4,7 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.network.client</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -21,10 +29,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.0"
audioplayers: audioplayers:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -117,10 +125,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -129,14 +153,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -165,10 +181,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.8"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -213,10 +229,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_macos name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.5" version: "0.9.4+4"
file_selector_platform_interface: file_selector_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -310,6 +326,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.9+2" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -346,10 +370,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.34" version: "2.0.31"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -368,22 +392,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.3" 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: http:
dependency: transitive dependency: transitive
description: description:
@@ -400,22 +408,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.1"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+17" version: "0.8.13+1"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@@ -428,10 +444,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+6" version: "0.8.13"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@@ -444,10 +460,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_macos name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2+1" version: "0.2.2"
image_picker_platform_interface: image_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -472,46 +488,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.19.0"
jni: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: jni name: json_annotation
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "4.9.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.2" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.10" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -520,38 +528,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.16.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -560,22 +560,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: octo_image:
dependency: transitive dependency: transitive
description: description:
@@ -584,14 +568,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -601,7 +577,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -612,18 +588,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.2.19"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.4.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -672,22 +648,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pub_semver: posix:
dependency: transitive dependency: transitive
description: description:
name: pub_semver name: posix
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "6.5.0"
record_use: record:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
record_android:
dependency: transitive dependency: transitive
description: description:
name: record_use name: record_android
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -700,26 +724,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.3"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.23" version: "2.4.13"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.4"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -732,10 +756,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.1"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@@ -761,34 +785,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.1"
sqflite: sqflite:
dependency: transitive dependency: transitive
description: description:
name: sqflite name: sqflite
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+1" version: "2.4.2"
sqflite_android: sqflite_android:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+3" version: "2.4.1"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.8" version: "2.5.6"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -833,10 +857,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0+1" version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -849,10 +873,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.4"
timeago: timeago:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -889,18 +913,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.1.4"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -942,5 +966,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.32.0"

View File

@@ -23,11 +23,17 @@ dependencies:
flutter_contacts: ^1.1.7 flutter_contacts: ^1.1.7
image_picker: ^1.0.7 image_picker: ^1.0.7
audioplayers: ^6.0.0 audioplayers: ^6.0.0
record: ^5.1.2
path_provider: ^2.1.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
flutter_launcher_icons: ^0.13.1
dependency_overrides:
record_platform_interface: 1.2.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -9,6 +9,7 @@
#include <audioplayers_windows/audioplayers_windows_plugin.h> #include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <record_windows/record_windows_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar( AudioplayersWindowsPluginRegisterWithRegistrar(
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
} }

View File

@@ -6,10 +6,10 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows audioplayers_windows
file_selector_windows file_selector_windows
firebase_core firebase_core
record_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

177
whatsapp_bridge/database.js Normal file
View 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
};

View File

@@ -14,7 +14,9 @@
"author": "Antigravity Dev Team", "author": "Antigravity Dev Team",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"dotenv": "^16.4.5",
"express": "^4.18.2", "express": "^4.18.2",
"firebase-admin": "^11.11.1",
"puppeteer": "^21.0.0", "puppeteer": "^21.0.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"whatsapp-web.js": "^1.26.0", "whatsapp-web.js": "^1.26.0",

File diff suppressed because it is too large Load Diff

View 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>