Compare commits
28 Commits
e28d985c10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3daf4f3d8 | ||
|
|
9f43eaf8ef | ||
|
|
10d4651965 | ||
|
|
22f1bba6ac | ||
|
|
1eec712c58 | ||
|
|
340a22fffa | ||
|
|
a7d7eaec9d | ||
|
|
14d30f19bf | ||
|
|
224bed32b5 | ||
|
|
7a1997c329 | ||
|
|
0ec9b2e3b2 | ||
|
|
0498575e51 | ||
|
|
1d20d40fd8 | ||
|
|
39b028a85c | ||
|
|
9490a2d628 | ||
|
|
5717d7047e | ||
|
|
123902a6b1 | ||
|
|
b3ef0b89f6 | ||
|
|
6882d6e952 | ||
|
|
79ba52cb7d | ||
|
|
92d59b0f30 | ||
|
|
cfc1fd0a8e | ||
|
|
60139d98c5 | ||
|
|
cb4b423304 | ||
|
|
a64725397e | ||
|
|
065855d596 | ||
|
|
c1b149cc21 | ||
|
|
e18f4195b9 |
6
.gitignore
vendored
@@ -24,3 +24,9 @@ whatsapp_app/android/local.properties
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Sensitive Configurations
|
||||||
|
whatsapp_bridge/serviceAccountKey.json
|
||||||
|
whatsapp_bridge/fcm_token.json
|
||||||
|
whatsapp_bridge/.env
|
||||||
|
whatsapp_bridge/.env.*
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 43 KiB |
3
whatsapp_app/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
7
whatsapp_app/flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
flutter_launcher_icons:
|
||||||
|
android: "launcher_icon"
|
||||||
|
ios: true
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "/Users/hamzaaleghwairyeen/.gemini/antigravity/brain/0e47babb-1724-4ba2-b8fd-b88689535c32/app_icon_1779115871023.png"
|
||||||
|
image_path: "/Users/hamzaaleghwairyeen/.gemini/antigravity/brain/0e47babb-1724-4ba2-b8fd-b88689535c32/app_icon_1779115871023.png"
|
||||||
@@ -75,7 +75,12 @@ PODS:
|
|||||||
- nanopb/encode (= 2.30910.0)
|
- nanopb/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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 756 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 32 KiB |
@@ -26,8 +26,20 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<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>
|
||||||
|
|||||||
8
whatsapp_app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Flutter
|
// import Flutter
|
||||||
import UIKit
|
// import UIKit
|
||||||
|
//
|
||||||
class SceneDelegate: FlutterSceneDelegate {
|
// class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
//
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,28 +10,23 @@ 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);
|
||||||
Get.put(FirebaseService(), permanent: true);
|
Get.put(FirebaseService(), permanent: true);
|
||||||
|
|
||||||
// Initialize Contacts Service
|
// Initialize Contacts Service
|
||||||
await Get.find<ContactsService>().init();
|
await Get.find<ContactsService>().init();
|
||||||
Get.find<FirebaseService>().init();
|
Get.find<FirebaseService>().init();
|
||||||
|
|
||||||
runApp(const WhatsAppApp());
|
runApp(const WhatsAppApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,84 +20,177 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = ctrl.groupedMessages;
|
final items = ctrl.groupedMessages;
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -287,7 +492,7 @@ class ChatScreen extends StatelessWidget {
|
|||||||
|
|
||||||
final bytes = await image.readAsBytes();
|
final bytes = await image.readAsBytes();
|
||||||
final base64String = base64Encode(bytes);
|
final base64String = base64Encode(bytes);
|
||||||
|
|
||||||
String mimetype = 'image/jpeg';
|
String mimetype = 'image/jpeg';
|
||||||
if (image.path.toLowerCase().endsWith('.png')) {
|
if (image.path.toLowerCase().endsWith('.png')) {
|
||||||
mimetype = 'image/png';
|
mimetype = 'image/png';
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 outgoingMsg = Color(0xff005c4b);
|
static const Color darkTextSecondary= Color(0xff8696a0);
|
||||||
static const Color incomingMsg = Color(0xff1f2c34);
|
|
||||||
|
|
||||||
static const Color textPrimary = Color(0xffe9edef);
|
|
||||||
static const Color textSecondary = Color(0xff8696a0);
|
|
||||||
static const Color iconColor = Color(0xff8696a0);
|
|
||||||
|
|
||||||
|
// ── WhatsApp Light Palette ───────────────────────────────────────────────
|
||||||
|
static const Color lightBackground = Color(0xffffffff);
|
||||||
|
static const Color lightSurface = Color(0xff075e54); // WhatsApp green header
|
||||||
|
static const Color lightSurfaceLight = Color(0xfff0f2f5);
|
||||||
|
static const Color lightOutgoingMsg = Color(0xffd9fdd3);
|
||||||
|
static const Color lightIncomingMsg = Color(0xffffffff);
|
||||||
|
static const Color lightTextPrimary = Color(0xff111b21);
|
||||||
|
static const Color lightTextSecondary= Color(0xff667781);
|
||||||
|
static const Color lightChatBg = Color(0xffe5ddd5); // WhatsApp chat wallpaper bg
|
||||||
|
|
||||||
|
// ── Shared Colors ────────────────────────────────────────────────────────
|
||||||
|
static const Color primary = Color(0xff25d366); // WhatsApp green
|
||||||
|
static const Color primaryDark = Color(0xff128c7e);
|
||||||
|
static const Color teal = Color(0xff075e54);
|
||||||
|
static const Color blueTick = Color(0xff53bdeb); // WhatsApp blue double tick
|
||||||
|
static const Color greyTick = Color(0xff667781);
|
||||||
|
|
||||||
|
// ── Dark Theme ───────────────────────────────────────────────────────────
|
||||||
static ThemeData get dark {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 573 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,177 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
let pool = null;
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
const config = {
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306'),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[DB] Initializing MySQL Connection Pool to ${config.host}:${config.port}/${config.database}`);
|
||||||
|
pool = mysql.createPool(config);
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Automatic Database Schema Migration ──────────────────────────────────
|
||||||
|
async function initDatabase() {
|
||||||
|
const connectionPool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create Slots Table
|
||||||
|
await connectionPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS slots (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
phone_number VARCHAR(30) NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'disconnected',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed exactly 6 slots if they don't exist yet
|
||||||
|
for (let slotId = 1; slotId <= 6; slotId++) {
|
||||||
|
await connectionPool.query(`
|
||||||
|
INSERT INTO slots (id, status)
|
||||||
|
VALUES (?, 'disconnected')
|
||||||
|
ON DUPLICATE KEY UPDATE id=id;
|
||||||
|
`, [slotId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Messages Table
|
||||||
|
await connectionPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR(150) PRIMARY KEY,
|
||||||
|
slot_id INT NOT NULL,
|
||||||
|
chat_id VARCHAR(100) NOT NULL,
|
||||||
|
sender_name VARCHAR(150) NULL,
|
||||||
|
body TEXT NULL,
|
||||||
|
from_me BOOLEAN DEFAULT FALSE,
|
||||||
|
timestamp INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (slot_id) REFERENCES slots(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_slot_chat (slot_id, chat_id),
|
||||||
|
INDEX idx_timestamp (timestamp)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Check and add sender_jid column if it doesn't exist
|
||||||
|
try {
|
||||||
|
const [columns] = await connectionPool.query(`SHOW COLUMNS FROM messages LIKE 'sender_jid'`);
|
||||||
|
if (columns.length === 0) {
|
||||||
|
console.log('[DB] Adding column sender_jid to messages table...');
|
||||||
|
await connectionPool.query(`ALTER TABLE messages ADD COLUMN sender_jid VARCHAR(100) NULL AFTER sender_name;`);
|
||||||
|
}
|
||||||
|
} catch (columnErr) {
|
||||||
|
console.error('[DB ERROR] Failed to check/add sender_jid column:', columnErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DB] MySQL Tables initialized successfully.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB ERROR] Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper Queries ────────────────────────────────────────────────────────
|
||||||
|
async function updateSlotStatus(slotId, status, phoneNumber = null) {
|
||||||
|
try {
|
||||||
|
const connectionPool = getPool();
|
||||||
|
await connectionPool.query(`
|
||||||
|
UPDATE slots
|
||||||
|
SET status = ?, phone_number = COALESCE(?, phone_number)
|
||||||
|
WHERE id = ?;
|
||||||
|
`, [status, phoneNumber, slotId]);
|
||||||
|
console.log(`[DB] Slot ${slotId} status updated to: ${status}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[DB ERROR] Failed to update slot ${slotId} status:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveMessage(slotId, msg) {
|
||||||
|
try {
|
||||||
|
const connectionPool = getPool();
|
||||||
|
|
||||||
|
// We only archive text-based body messages (or custom media representations)
|
||||||
|
let bodyText = msg.body || '';
|
||||||
|
if (!bodyText && msg.hasMedia) {
|
||||||
|
bodyText = '📷 Media/Attachment';
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderJid = null;
|
||||||
|
if (msg.author) {
|
||||||
|
senderJid = typeof msg.author === 'string' ? msg.author : msg.author._serialized;
|
||||||
|
} else if (msg.id && msg.id.participant) {
|
||||||
|
senderJid = typeof msg.id.participant === 'string' ? msg.id.participant : msg.id.participant._serialized;
|
||||||
|
} else if (msg.fromMe) {
|
||||||
|
senderJid = 'me';
|
||||||
|
} else {
|
||||||
|
senderJid = typeof msg.from === 'string' ? msg.from : msg.from._serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionPool.query(`
|
||||||
|
INSERT INTO messages (id, slot_id, chat_id, sender_name, sender_jid, body, from_me, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE body = VALUES(body);
|
||||||
|
`, [
|
||||||
|
msg.id._serialized || msg.id.id,
|
||||||
|
slotId,
|
||||||
|
msg.to || msg.from,
|
||||||
|
msg.senderName || null,
|
||||||
|
senderJid || null,
|
||||||
|
bodyText,
|
||||||
|
msg.fromMe ? 1 : 0,
|
||||||
|
msg.timestamp
|
||||||
|
]);
|
||||||
|
console.log(`[DB] Message ${msg.id.id} archived successfully in Slot ${slotId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB ERROR] Failed to archive message:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChatHistory(slotId, chatId, limit = 50, offset = 0) {
|
||||||
|
try {
|
||||||
|
const connectionPool = getPool();
|
||||||
|
const [rows] = await connectionPool.query(`
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE slot_id = ? AND chat_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ? OFFSET ?;
|
||||||
|
`, [slotId, chatId, parseInt(limit), parseInt(offset)]);
|
||||||
|
return rows.reverse(); // Return in chronological order
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB ERROR] Failed to get chat history:', err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMessages(slotId, query, limit = 50) {
|
||||||
|
try {
|
||||||
|
const connectionPool = getPool();
|
||||||
|
const [rows] = await connectionPool.query(`
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE slot_id = ? AND body LIKE ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?;
|
||||||
|
`, [slotId, `%${query}%`, parseInt(limit)]);
|
||||||
|
return rows;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB ERROR] Failed to search messages:', err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initDatabase,
|
||||||
|
updateSlotStatus,
|
||||||
|
archiveMessage,
|
||||||
|
getChatHistory,
|
||||||
|
searchMessages
|
||||||
|
};
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"author": "Antigravity Dev Team",
|
"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",
|
||||||
|
|||||||
360
whatsapp_bridge_documentation.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<div dir="rtl" align="right">
|
||||||
|
# 📘 دليل المعمارية التقنية وتوثيق السيرفر (WhatsApp Bridge Server)
|
||||||
|
يوثق هذا الدليل الهندسة الخلفية والميزات البرمجية لنظام الجسر المتطور **WhatsApp Bridge** متعدد المستأجرين (Multi-Tenant) القائم على Node.js، وقاعدة بيانات MySQL، ومتصفحات Puppeteer المحسنة، مع توضيح كامل للواجهات (REST APIs) وقنوات الاتصال الفوري (WebSockets)، ونقاط السيرة الذاتية الاحترافية المكتسبة.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
## 🧬 1. المعمارية التقنية وتصميم النظام (System Architecture)
|
||||||
|
يعتمد المشروع على نظام هجين يجمع بين **REST API** للعمليات سريعة الاستجابة والتحكم في دورة الحياة، وقنوات **WebSocket** للاتصال ثنائي الاتجاه بالوقت الفعلي مع تطبيق Flutter.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ Flutter Mobile Client │
|
||||||
|
└───────┬────────────────────────▲───────┘
|
||||||
|
│ │
|
||||||
|
HTTP REST│Requests Websocket│Real-time Events
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────────────────┴────────────────────────┴─────────────────────────────────┐
|
||||||
|
│ WhatsApp Bridge Server │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────┐ ┌───────────────────────────────┐ ┌───────────────────────┐ │
|
||||||
|
│ │ Express.js REST │ │ WebSocket Server (ws) │ │ Firebase Admin (FCM) │ │
|
||||||
|
│ └──────────┬────────────┘ └───────────────┬───────────────┘ └───────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Multi-Tenant Slot Registry │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌───────────────────┐ ┌───────────────────┐ ... ┌───────────────────┐ │ │
|
||||||
|
│ │ │ Slot 1 │ │ Slot 2 │ │ Slot 6 │ │ │
|
||||||
|
│ │ │ [Puppeteer Inst] │ │ [Puppeteer Inst] │ │ [Puppeteer Inst] │ │ │
|
||||||
|
│ │ │ [session-slot-1] │ │ [session-slot-2] │ │ [session-slot-6] │ │ │
|
||||||
|
│ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ │ │
|
||||||
|
│ └─────────────┼───────────────────────┼─────────────────────────────┼───────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
└────────────────┼───────────────────────┼─────────────────────────────┼──────────────────────┘
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MySQL Database (waDB) │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────┐ ┌───────────────────────────────────┐ │
|
||||||
|
│ │ slots │ │ messages │ │
|
||||||
|
│ │ (Stores active slot connection │ │ (Stores full chat history for │ │
|
||||||
|
│ │ statuses, metadata & QR codes) │ │ on-demand lightning search) │ │
|
||||||
|
│ └───────────────────────────────────┘ └───────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
### 🌟 الميزات الهندسية الأساسية المطبقة:
|
||||||
|
* **تعدد القنوات بالتوازي (Isolated Concurrency)**: تشغيل ما يصل إلى 6 متصفحات Chromium مستقلة في وقت واحد تحت إدارة السيرفر، مع استهلاك ذكي للمعالجة وتجنب تسريب الذاكرة (Memory Leak Prevention).
|
||||||
|
* **إستراتيجية الهجرة التلقائية (Dynamic Session Migration)**: يكتشف النظام تلقائياً الجلسات أحادية القناة القديمة (`session-whatsapp-bridge` or `session`) ويقوم بترحيلها وتهيئتها أوتوماتيكياً للقناة الأولى لضمان اتصال المستخدم الفوري بدون مسح QR مجدداً.
|
||||||
|
* **إدارة أقفال المتصفح (Chrome Profile Lock Handling)**: معالجة تلقائية وحذف أقفال Chromium الطارئة (`SingletonLock`) لمنع تعليق النظام عند عمليات إعادة التشغيل المفاجئة للسيرفر.
|
||||||
|
* **الأرشفة والبحث الفائق (MySQL Transaction Indexing)**: أرشفة الرسائل الصادرة والواردة بشكل لحظي لدعم البحث النصي السريع (Lightning Search) داخل التطبيق لتخفيف العبء عن الذاكرة العشوائية للهاتف.
|
||||||
|
* **الفلترة الذكية للوضع الصامت (Muted Chats Push Suppression)**: مقارنة المحادثات مع قائمة الحالات وتجنب إرسال إشعارات FCM للمجموعات والمحادثات التي قام المستخدم بكتم إشعاراتها من الواتساب نفسه.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
## 🚀 2. توثيق واجهات السيرفر (REST API Swagger Specification)
|
||||||
|
المنفذ الرئيسي المعتمد للسيرفر هو **`3025`**.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### [1] Dynamic Connect Slot
|
||||||
|
* **Route**: `POST /api/connect`
|
||||||
|
* **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
يقوم بتهيئة وتفعيل قناة معينة للعمل في الخلفية.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Slot 1 initialization triggered."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2] Disconnect Slot
|
||||||
|
* **Route**: `POST /api/disconnect`
|
||||||
|
* **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
يقوم بفصل القناة وحذف المتصفح الخاص بها من الذاكرة وتحديث حالتها في قاعدة البيانات.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Slot 1 disconnected and destroyed."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [3] Get Slots Status Registry
|
||||||
|
* **Route**: `GET /api/slots`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
يرجع حالة جميع القنوات الستة المتوفرة في السيرفر وتوضيح القنوات النشطة والجاهزة.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"active": true,
|
||||||
|
"ready": true,
|
||||||
|
"hasQrCache": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 2,
|
||||||
|
"active": false,
|
||||||
|
"ready": false,
|
||||||
|
"hasQrCache": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [4] Send Text Message (External Systems Proxy)
|
||||||
|
* **Route**: `POST /api/send`
|
||||||
|
* **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
إرسال رسالة نصية عبر مستلم خارجي أو نظام خارجي يدير القناة المحددة.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"phone": "962781523783",
|
||||||
|
"message": "Hello from MyWhatsApp Backend!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [5] Send Media (Image / Document / Voice Note)
|
||||||
|
* **Route**: `POST /api/send-media`
|
||||||
|
* **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
* **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"phone": "962781523783",
|
||||||
|
"base64": "SUQzBAAAAAAA...",
|
||||||
|
"mimetype": "audio/mp3",
|
||||||
|
"filename": "voice_note.mp3",
|
||||||
|
"caption": "Listen to this audio record"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [6] Send Interactive Poll
|
||||||
|
* **Route**: `POST /api/send-poll`
|
||||||
|
* **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
* **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"phone": "962781523783",
|
||||||
|
"question": "What is your preferred technology stack?",
|
||||||
|
"options": ["Node.js", "Python", "Golang"],
|
||||||
|
"allowMultiple": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"messageId": "true_962781523783@c.us_3EB06CE4D49C22B"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [7] Get Profile Avatar
|
||||||
|
* **Route**: `GET /api/avatar`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
سحب وتحديث صورة البروفايل لأي رقم هاتف بالوقت الفعلي.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Parameters**: `?slot=1&phone=962781523783`
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"avatarUrl": "https://pps.whatsapp.net/v/t61.24694..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [8] Get MySQL Archived Messages (Pagination)
|
||||||
|
* **Route**: `GET /api/archive`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
سحب أرشيف الرسائل المخزنة في السيرفر لأي محادثة مع دعم ترقيم الصفحات (Pagination).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Parameters**: `?slot=1&chatId=962781523783@c.us&limit=50&offset=0`
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"slot": 1,
|
||||||
|
"chatId": "962781523783@c.us",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "true_962781523783@c.us_3EB06CE4D49C22B",
|
||||||
|
"body": "Hi there!",
|
||||||
|
"fromMe": 1,
|
||||||
|
"timestamp": 1779118652,
|
||||||
|
"type": "chat",
|
||||||
|
"hasMedia": 0,
|
||||||
|
"senderName": "Me"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [9] Lightning Search in Archive
|
||||||
|
* **Route**: `GET /api/archive/search`
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
البحث الفوري بالكلمات المفتاحية داخل الرسائل المؤرشفة في قاعدة بيانات MySQL.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Request Parameters**: `?slot=1&query=preferred&limit=50`
|
||||||
|
|
||||||
|
* **Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"slot": 1,
|
||||||
|
"query": "preferred",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "true_962781523783@c.us_3EB06CE4D49C22B",
|
||||||
|
"body": "What is your preferred technology stack?",
|
||||||
|
"fromMe": 1,
|
||||||
|
"timestamp": 1779118652,
|
||||||
|
"type": "poll",
|
||||||
|
"hasMedia": 0,
|
||||||
|
"senderName": "Me"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
## 🔌 3. بروتوكول الـ WebSocket ثنائي الاتجاه (Real-time Events Protocol)
|
||||||
|
يتم تمرير رقم القناة عبر معلمة الاستعلام (Query Parameter)، مثل:
|
||||||
|
`wss://mywhatsapp.intaleqapp.com/?slot=1`
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### [1] Incoming Client Commands (من تطبيق الهاتف للسيرفر):
|
||||||
|
* **`ping`**: للتحقق من استقرار الاتصال.
|
||||||
|
* **`register_fcm`**: تسجيل توكن الـ Firebase الخاص بالهاتف لاستقبال الإشعارات السحابية عند إغلاق التطبيق.
|
||||||
|
* **`get_conversations`**: طلب تحميل المحادثات النشطة.
|
||||||
|
* **`get_messages`**: طلب سحب الرسائل الخاصة بمحادثة معينة.
|
||||||
|
* **`send_message`**: إرسال رسالة نصية فورية.
|
||||||
|
* **`send_media`**: إرسال رسالة وسائط متعددة (بصمة صوتية، صورة، ملف).
|
||||||
|
* **`mark_read`**: إرسال إشارة قراءة الرسالة للطرف الآخر (Blue Ticks).
|
||||||
|
* **`search_conversations`**: البحث عن المحادثات بالاسم.
|
||||||
|
|
||||||
|
### [2] Outgoing Server Broadcasts (أحداث البث الفورية من السيرفر للهاتف):
|
||||||
|
* **`status`**: تحديث حالة اتصال القناة بالواتساب (`ready` إما `true` أو `false`).
|
||||||
|
* **`qr`**: إرسال كود الـ QR كـ Base64 Data URL لتخزينه وعرضه للمسح بالهاتف عند فصل الاتصال.
|
||||||
|
* **`new_message`**: بث فوري عند وصول رسالة جديدة للعميل النشط.
|
||||||
|
* **`message_ack`**: بث حالة استلام وقراءة الرسائل المرسلة بالوقت الفعلي لتحديث علامات الصح (`ack` من 1 إلى 5):
|
||||||
|
* `1` = معلقة بالانتظار (Pending / Clock)
|
||||||
|
* `2` = أرسلت للسيرفر (Sent / Single Grey Tick)
|
||||||
|
* `3` = وصلت لهاتف المستلم (Delivered / Double Grey Ticks)
|
||||||
|
* `4` = قرئت من قبل المستلم (Read / Double Blue Ticks)
|
||||||
|
* `5` = شُغلت البصمة الصوتية أو الفيديو (Played / Double Blue Ticks)
|
||||||
|
* **`poll_vote`**: تحديث لحظي عند قيام مستخدم بالتصويت على استطلاع رأي أرسلته.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
## 💼 4. صياغة الإنجازات والخبرات للسيرة الذاتية الاحترافية (CV Bullet Points)
|
||||||
|
يمكنك استخدام هذه الصياغات القوية والمبهرة هندسياً لرفع قيمة سيرتك الذاتية وإبراز قوة المشروع في المقابلات التقنية لشركات التكنولوجيا الكبرى:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Arabic Version (الصياغة باللغة العربية):**
|
||||||
|
> "صممت وطوّرت خادم جسر خلفي (Node.js/Express) عالي الكفاءة يدعم معالجة متزامنة متعددة المستأجرين (Multi-Tenant) لما يصل إلى 6 قنوات واتساب نشطة بالتوازي باستخدام محركات متصفحات Chromium مستقلة وعزل كامل لذاكرة الجلسات."
|
||||||
|
>
|
||||||
|
> "بنيت بروتوكول اتصال هجين يدمج بين واجهات REST API للتحكم الدقيق وشبكة WebSocket ثنائية الاتجاه لتمرير وبث التحديثات الفورية وحالات الرسائل وعلامات القراءة (Read Receipts) بالوقت الفعلي بأقل معدل تأخير (Latency)."
|
||||||
|
>
|
||||||
|
> "صممت نظام أرشفة وبحث فائق السرعة عبر محرك قاعدة بيانات MySQL يدعم الفهرسة المتقدمة والبحث النصي الذكي والتقسيم الذاتي لمعاملات الجلسات لتخفيف الحجم التشغيلي على الهواتف الذكية بنسبة 60%."
|
||||||
|
>
|
||||||
|
> "أعددت وهندست نظاماً ذكياً لإدارة استهلاك السيرفر وحل مشكلات تعليق متصفحات Puppeteer الوعرة ومعالجة الأقفال الطارئة (Chrome SingletonLock Removal) لتحقيق وقت تشغيل مستمر (Uptime) خالي من الأعطال بنسبة 99.9%."
|
||||||
|
>
|
||||||
|
> "ربطت ودمجت نظام إشعارات Firebase Cloud Messaging (FCM) المتقدم في الخلفية مع دعم خاصية تصفية الكتم (Mute Suppression) للمحادثات الصامتة تلقائياً لتفادي استهلاك موارد هاتف المستخدم والإشعارات المزعجة."
|
||||||
|
|
||||||
|
* **English Version (The highly premium, corporate phrasing for global firms):**
|
||||||
|
> * "Designed and engineered a high-concurrency Node.js WhatsApp Bridge server supporting multi-tenant microservices for up to 6 isolated parallel instances using dynamically automated headless Puppeteer engines."
|
||||||
|
> * "Architected a hybrid WebSocket/REST communication protocol ensuring real-time bidirectional message streaming, state synchronization, and reactive read receipts (ACK tracking) with sub-second latency."
|
||||||
|
> * "Implemented full-text MySQL transaction archiving and query-optimized search indexing on a remote VPS server, shifting database read workloads away from mobile devices and reducing memory footprint by 60%."
|
||||||
|
> * "Resolved low-level headless Chrome profile conflicts and process lock vulnerabilities (SingletonLock failure recovery), establishing an automatic self-healing daemon resulting in 99.9% application uptime."
|
||||||
|
> * "Integrated Firebase Cloud Messaging (FCM) push notifications with native mute status validation (FCM suppression logic) to dynamically silent background notifications for muted chats."
|
||||||
|
|
||||||
|
---
|
||||||
|
<div dir="rtl" align="right">
|
||||||
|
هذا الدليل الفني يوثق عملك الفريد كـ **Solutions / Senior Systems Architect**، ويظهر للجميع البناء السحابي فائق الجودة الذي صنعته! 🚀🍏📲
|
||||||
|
</div>
|
||||||