From 8aa40f7b8a7046df73b10c4a773ef2968668a5b6 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:08:00 +0300 Subject: [PATCH] one to one calling implemented --- ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md | 1367 --------------- android/app/src/main/AndroidManifest.xml | 10 + assets/audio/outgoing_ringtone.mp3 | Bin 0 -> 69798 bytes debug_video_call.sh | 1 + ios/Runner/Info.plist | 2 + .../cx_module/chat/call/audio_call_page.dart | 199 ++- .../chat/call/call_debug_helper.dart | 92 + .../chat/call/call_error_handler.dart | 471 ++++++ .../chat/call/incoming_call_dialog.dart | 216 +++ .../chat/call/services/callkit_service.dart | 386 +++++ .../chat/call/services/webrtc_service.dart | 617 +++++++ .../cx_module/chat/call/video_call_page.dart | 771 +++++++-- lib/modules/cx_module/chat/chat_provider.dart | 1483 ++++++++++++----- 13 files changed, 3594 insertions(+), 2021 deletions(-) delete mode 100644 ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md create mode 100644 assets/audio/outgoing_ringtone.mp3 create mode 100644 debug_video_call.sh create mode 100644 lib/modules/cx_module/chat/call/call_debug_helper.dart create mode 100644 lib/modules/cx_module/chat/call/call_error_handler.dart create mode 100644 lib/modules/cx_module/chat/call/incoming_call_dialog.dart create mode 100644 lib/modules/cx_module/chat/call/services/webrtc_service.dart diff --git a/ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md b/ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md deleted file mode 100644 index e1019656..00000000 --- a/ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md +++ /dev/null @@ -1,1367 +0,0 @@ -# ATOMS Performance Optimization - Complete Implementation Guide - -**Project:** ATOMS Medical Device Management System (Flutter) -**Implementation Date:** April 12, 2026 -**Implementation Time:** 145 minutes (2.4 hours) -**Performance Gain:** 60-80% overall improvement -**Status:** βœ… PRODUCTION-READY & TESTED - ---- - -## πŸ“‹ TABLE OF CONTENTS - -1. [Executive Summary](#executive-summary) -2. [Complete Change Log](#complete-change-log) -3. [Performance Metrics](#performance-metrics) -4. [Code Patterns & Best Practices](#code-patterns--best-practices) -5. [Validation Prompt for Future Development](#validation-prompt) -6. [Quality Checklist](#quality-checklist) - ---- - -## 🎯 EXECUTIVE SUMMARY - -This document contains **ALL performance optimizations** implemented in ATOMS. Use this as the **single source of truth** for maintaining and validating optimizations. - -### What Was Optimized: - -βœ… **Phase 1:** Lazy Provider Loading - 68% faster startup -βœ… **Phase 2A:** Selector Optimization - 71% fewer rebuilds -βœ… **Phase 2A.1:** Chat Message Refresh Fix - Real-time updates work -βœ… **Phase 3A:** JSON Parsing Fix - 15-20% faster APIs -βœ… **Phase 3B:** API Response Caching - 60-80% fewer network calls -βœ… **Phase 3C:** HTTP Connection Pooling - 100-200ms saved per call -βœ… **Phase 4A:** SignalR Connection Leak - Zero memory leaks - -### Overall Impact: - -``` -App Launch: 2.5s β†’ 0.8s (68% faster) βœ… -Memory Usage: 180MB β†’ 85MB (53% less) βœ… -Dashboard Load: 5.2s β†’ 1.5s (71% faster) βœ… -Widget Rebuilds: 20-30 β†’ 5-8 (71% fewer) βœ… -API Calls: 100 β†’ 40-64 (36-60% fewer) βœ… -Dropdown Speed: 500ms β†’ 0ms (instant when cached) βœ… -Memory Leaks: Risk β†’ None (eliminated) βœ… -``` - ---- - -## πŸ“ COMPLETE CHANGE LOG - -### Total Changes: -- **Files Modified:** 17 files -- **Lines Changed:** ~427 lines -- **Helper Classes Added:** 9 classes -- **Breaking Changes:** 0 -- **Compilation Errors:** 0 - ---- - -## PHASE 1: LAZY PROVIDER LOADING - -### File: lib/main.dart - -**Location:** Lines 207-318 (Provider declarations) - -**Changes Made:** - -1. Reorganized 117 providers into two categories -2. Added organizational comments -3. Added lazy: true to 107 providers - -**Before:** -```dart -MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => UserProvider()), - ChangeNotifierProvider(create: (_) => DashBoardProvider()), - ChangeNotifierProvider(create: (_) => GasTypesProvider()), - ChangeNotifierProvider(create: (_) => ClassificationLookupProvider()), - // ... ALL 117 providers loaded at startup - ], -) -``` - -**After:** -```dart -MultiProvider( - providers: [ - // CORE PROVIDERS (10) - Critical for app launch - ChangeNotifierProvider(create: (_) => UserProvider()), - ChangeNotifierProvider(create: (_) => DashBoardProvider()), - ChangeNotifierProvider(create: (_) => NotificationsProvider()), - ChangeNotifierProvider(create: (_) => AllRequestsProvider()), - ChangeNotifierProvider(create: (_) => ServiceRequestsProvider()), - ChangeNotifierProvider(create: (_) => AssetProvider()), - ChangeNotifierProvider(create: (_) => HospitalsProvider()), - ChangeNotifierProvider(create: (_) => DepartmentsProvider()), - ChangeNotifierProvider(create: (_) => NullableLoadingProvider()), - ChangeNotifierProvider(create: (_) => ChatProvider()), - - // LAZY PROVIDERS (107) - Load on demand - ChangeNotifierProvider(create: (_) => ClassificationLookupProvider(), lazy: true), - ChangeNotifierProvider(create: (_) => RecommendationLookupProvider(), lazy: true), - ChangeNotifierProvider(create: (_) => GasTypesProvider(), lazy: true), - // ... 104 more with lazy: true - ], -) -``` - -**Key Pattern:** -```dart -// Only 10 core providers without lazy: true -// All others MUST have lazy: true -``` - -**Impact:** -- Startup time: 2.5s β†’ 0.8s (68% faster) -- Initial memory: 180 MB β†’ 85 MB (53% less) -- Providers loaded: 117 β†’ 10 (91% reduction) - ---- - -## PHASE 2A: SELECTOR OPTIMIZATION - -### File 1: lib/dashboard_latest/widgets/requests_fragment.dart - -**Changes Made:** - -1. Added helper class _DashboardCountData at top of file -2. Replaced Consumer with Selector -3. Updated all references from snapshot to data - -**Code Added (Top of File):** -```dart -import 'package:test_sa/models/new_models/dashboard_count.dart'; - -class _DashboardCountData { - final DashboardCount? dashboardCount; - final bool isLoading; - - const _DashboardCountData({ - required this.dashboardCount, - required this.isLoading, - }); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _DashboardCountData && - dashboardCount == other.dashboardCount && - isLoading == other.isLoading; - - @override - int get hashCode => Object.hash(dashboardCount, isLoading); -} -``` - -**Code Changed (build method, line ~49):** -```dart -// BEFORE: -return Consumer( - builder: (context, snapshot, _) => GridView( - children: [ - listItem(snapshot.dashboardCount?.data?.countHighPriority ?? 0, ...), - ], - ), -); - -// AFTER: -return Selector( - selector: (_, provider) => _DashboardCountData( - dashboardCount: provider.dashboardCount, - isLoading: provider.isAllCountLoading, - ), - builder: (context, data, _) => GridView( - children: [ - listItem(data.dashboardCount?.data?.countHighPriority ?? 0, ...), - ], - ), -); -``` - -**Impact:** 70% fewer rebuilds on dashboard - ---- - -### File 2: lib/modules/cx_module/chat/chat_page.dart - -**Changes Made:** - -1. Added imports for ChatLoginResponse and Participants -2. Added 4 helper classes for state selection -3. Split single Consumer into 4 targeted Selectors - -**Imports Added (line ~28-30):** -```dart -import 'model/chat_login_response_model.dart'; -import 'model/chat_participant_model.dart'; -``` - -**Helper Classes Added (after imports, line ~35-115):** -```dart -class _ChatConnectionState { - final bool isLoading; - final ChatLoginResponse? loginResponse; - - const _ChatConnectionState({required this.isLoading, required this.loginResponse}); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _ChatConnectionState && - isLoading == other.isLoading && - loginResponse == other.loginResponse; - - @override - int get hashCode => Object.hash(isLoading, loginResponse); -} - -class _ChatHeaderState { - final Participants? recipient; - final bool isTyping; - - const _ChatHeaderState({required this.recipient, required this.isTyping}); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _ChatHeaderState && - recipient == other.recipient && - isTyping == other.isTyping; - - @override - int get hashCode => Object.hash(recipient, isTyping); -} - -class _ChatMessagesState { - final bool isLoading; - final List messages; - - const _ChatMessagesState({required this.isLoading, required this.messages}); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _ChatMessagesState && - isLoading == other.isLoading && - messages.length == other.messages.length; - - @override - int get hashCode => Object.hash(isLoading, messages.length); -} - -class _ChatSendButtonState { - final bool isSending; - - const _ChatSendButtonState({required this.isSending}); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _ChatSendButtonState && isSending == other.isSending; - - @override - int get hashCode => isSending.hashCode; -} -``` - -**Widget Structure Changed (line ~198):** -```dart -// BEFORE: Single Consumer -body: Consumer(builder: (context, chatProvider, child) { - if (chatProvider.chatLoginTokenLoading) return Loading(); - // ... entire chat UI -}) - -// AFTER: Multiple Selectors -body: Selector( - selector: (_, provider) => _ChatConnectionState( - isLoading: provider.chatLoginTokenLoading, - loginResponse: provider.chatLoginResponse, - ), - builder: (context, connectionState, child) { - if (connectionState.isLoading) return Loading(); - - final chatProvider = Provider.of(context, listen: false); - - return Column( - children: [ - // Header Selector - Selector( - selector: (_, p) => _ChatHeaderState( - recipient: p.recipient, - isTyping: p.isTyping, - ), - builder: (context, headerState, _) => Header(...), - ), - - // Messages Selector - Selector( - selector: (_, p) => _ChatMessagesState( - isLoading: p.userChatHistoryLoading, - messages: p.chatResponseList, - ), - builder: (context, messagesState, _) => MessageList(...), - ), - - // Send Button Selector - IconButton( - icon: Selector( - selector: (_, p) => _ChatSendButtonState(isSending: p.messageIsSending), - builder: (_, sendState, __) => sendState.isSending - ? CircularProgressIndicator() - : SendIcon(), - ), - ), - ], - ); - }, -) -``` - -**Impact:** 80% fewer rebuilds in real-time chat - ---- - -### File 3: lib/modules/asset_delivery_module/pages/asset_delivery_page.dart - -**Changes Made:** - -Replaced Consumer with Selector targeting single property - -**Code Changed (line ~82):** -```dart -// BEFORE: -body: Consumer( - builder: (context, provider, child) { - final dataModel = provider.assetDeliveryDataModel; - return SingleChildScrollView(...); - } -) - -// AFTER: -body: Selector( - selector: (_, provider) => provider.assetDeliveryDataModel, - builder: (context, dataModel, child) { - final provider = Provider.of(context, listen: false); - return SingleChildScrollView(...); - } -) -``` - -**Impact:** 60% fewer rebuilds on asset delivery pages - ---- - -## PHASE 3A: JSON PARSING OPTIMIZATION - -### File: lib/controllers/api_routes/api_manager.dart - -**Changes Made:** - -Fixed double JSON parsing in all 5 HTTP methods by parsing once and reusing the result. - -**Pattern Applied to All Methods:** - -```dart -// BEFORE (inefficient): -try { - if (response.statusCode == 401) { - showLoginDialog(); - } else { - if (jsonDecode(response.body) is Map) { // Parse #1 - final message = jsonDecode(response.body)["message"]; // Parse #2 - if (message != null && message.toString().isNotEmpty) { - Fluttertoast.showToast(msg: message ?? ""); - } - } - } -} catch (ex) {} - -// AFTER (optimized): -// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before) -try { - if (response.statusCode == 401) { - showLoginDialog(); - } else { - // Parse response body only once - final responseBody = jsonDecode(response.body); - if (responseBody is Map) { - final message = responseBody["message"]; // Reuse parsed body - if (message != null && message.toString().isNotEmpty) { - Fluttertoast.showToast(msg: message); - } - } - } -} catch (ex) {} -``` - -**Methods Fixed:** -1. GET method (line ~38-54) -2. POST method (line ~77-91) -3. DELETE method (line ~108-122) -4. PUT method (line ~169-183) -5. multiPart method (line ~214-228) - -**Impact:** -- 50% fewer JSON parsing operations -- 15-20% faster API response handling -- Less CPU usage per API call - ---- - -## PHASE 3B: API RESPONSE CACHING - -### File: lib/controllers/api_routes/api_manager.dart - -**Changes Made:** - -1. Added _CachedResponse helper class -2. Added cache storage Map and configuration -3. Enhanced GET method with caching parameters -4. Added cache management methods -5. Updated logout to clear cache - -**Code Added:** - -```dart -// 1. Helper class (line ~17-28) -class _CachedResponse { - final http.Response response; - final DateTime timestamp; - - _CachedResponse(this.response, this.timestamp); - - bool isValid(Duration cacheDuration) { - return DateTime.now().difference(timestamp) < cacheDuration; - } -} - -// 2. Cache storage (line ~46-49) -static final Map _cache = {}; -static const Duration _defaultCacheDuration = Duration(hours: 1); - -// 3. Cache methods (line ~51-68) -void clearCache() { - _cache.clear(); -} - -void clearCacheEntry(String url) { - _cache.remove(_generateCacheKey(url)); -} - -String _generateCacheKey(String url) { - return '${user?.id ?? 'guest'}_${assetGroup?.id ?? 'all'}_$url'; -} - -// 4. Enhanced GET method signature (line ~70) -Future get( - String url, { - Map? headers, - bool enableToastMessage = true, - bool useCache = false, // NEW - Duration? cacheDuration, // NEW - bool forceRefresh = false, // NEW -}) async { - // Cache check logic - if (useCache && !forceRefresh) { - final cacheKey = _generateCacheKey(url); - final cachedResponse = _cache[cacheKey]; - - if (cachedResponse != null && cachedResponse.isValid(cacheDuration ?? _defaultCacheDuration)) { - if (kDebugMode) { - print('πŸ“¦ Cache HIT: $url'); - } - return cachedResponse.response; - } - } - - // Fetch... - - // Cache the response - if (useCache && response.statusCode >= 200 && response.statusCode < 300) { - final cacheKey = _generateCacheKey(url); - _cache[cacheKey] = _CachedResponse(response, DateTime.now()); - if (kDebugMode) { - print('πŸ’Ύ Cached: $url'); - } - } -} - -// 5. Updated logout (line ~355-362) -void logout(context) async { - clearCache(); // Clear cache on logout - await Provider.of(context, listen: false).resetSettings(); - // ... rest -} -``` - -**Impact:** -- 60-80% fewer network calls for cached data -- Instant dropdown population on repeat access -- Better server efficiency - ---- - -### Files: 15 Lookup Providers (Caching Enabled) - -**Pattern Applied to All:** - -```dart -// BEFORE: -Response response = await ApiManager.instance.get(URLs.lookupUrl); - -// AFTER: -Response response = await ApiManager.instance.get( - URLs.lookupUrl, - useCache: true, - enableToastMessage: false, -); -``` - -**Files Modified:** - -1. lib/providers/lookups/classification_lookup_provider.dart -2. lib/providers/lookups/recommendation_lookup_provider.dart -3. lib/providers/lookups/request_type_lookup_provider.dart -4. lib/providers/lookups/yes_no_lookup_provider.dart -5. lib/providers/lookups/department_lookup_provider.dart -6. lib/modules/asset_delivery_module/provider/end_user_status_lookup_provider.dart -7. lib/modules/asset_delivery_module/provider/end_user_rejection_reason_lookup_provider.dart -8. lib/modules/demo_module/provider/demo_period_lookup_provider.dart -9. lib/modules/demo_module/demo_document_lookup_provider.dart -10. lib/modules/incident_module/incident_lookup_provider.dart (contains 6 provider classes) -11. lib/modules/incident_module/incident_type_lookup_provider.dart - -**Total Lookup Providers Cached:** 15+ providers - ---- - -## PHASE 3C: HTTP CONNECTION POOLING - -### File: lib/controllers/api_routes/api_manager.dart - -**Changes Made:** - -1. Added persistent HTTP client -2. Updated all HTTP methods to use persistent client -3. Added dispose method - -**Code Added:** - -```dart -// 1. Persistent client (line ~46) -static final http.Client _httpClient = http.Client(); - -// 2. Updated GET method (line ~103) -// BEFORE: -http.Response response = await http.get(url0, headers: headers); - -// AFTER: -http.Response response = await _httpClient.get(url0, headers: headers); - -// 3. Updated POST/DELETE/PUT/multiPart methods -// BEFORE: -http.StreamedResponse streamedResponse = await request.send(); - -// AFTER: -http.StreamedResponse streamedResponse = await _httpClient.send(request); - -// 4. Added dispose (line ~367-370) -void dispose() { - _httpClient.close(); -} -``` - -**Impact:** -- 100-200ms faster per subsequent API call -- Connection reuse across all requests -- Better resource management - ---- - -## PHASE 4A: SIGNALR CONNECTION LEAK FIX - -### File: lib/modules/cx_module/chat/chat_provider.dart - -**Changes Made:** - -1. Added _disposeConnection helper method -2. Made reset() async -3. Added dispose() override -4. Improved buildHubConnection with error handling -5. Enhanced connectToHub with error handling - -**Code Added/Modified:** - -```dart -// 1. Helper method (line ~91-109) -Future _disposeConnection() async { - try { - if (chatHubConnection != null) { - await chatHubConnection!.stop(); - if (kDebugMode) { - print('πŸ”Œ SignalR connection closed successfully'); - } - } - } catch (e) { - if (kDebugMode) { - print('⚠️ Error closing SignalR connection: $e'); - } - } finally { - chatHubConnection = null; - } -} - -// 2. Modified reset() (line ~111-125) -// BEFORE: -void reset() { - chatHubConnection?.stop().then((value) { - chatHubConnection = null; - }); - // ... -} - -// AFTER: -Future reset() async { - await _disposeConnection(); - chatLoginTokenLoading = false; - // ... -} - -// 3. Added dispose() (line ~127-138) -@override -void dispose() { - _disposeConnection().then((_) { - if (kDebugMode) { - print('βœ… ChatProvider disposed'); - } - }).catchError((error) { - if (kDebugMode) { - print('⚠️ Error during ChatProvider disposal: $error'); - } - }); - super.dispose(); -} - -// 4. Improved buildHubConnection() (line ~294-326) -Future buildHubConnection(String conversationID) async { - try { - await _disposeConnection(); // Clean existing first - chatHubConnection = await getHubConnection(); - await chatHubConnection!.start(); - // ... setup listeners - } catch (e) { - if (kDebugMode) { - print('⚠️ Error building SignalR connection: $e'); - } - await _disposeConnection(); - rethrow; - } -} - -// 5. Enhanced connectToHub() (line ~240-269) -if (!readOnly) { - try { - await buildHubConnection(chatParticipantModel!.id!.toString()); - } catch (e) { - if (kDebugMode) { - print('⚠️ Failed to build hub connection: $e'); - } - } -} - -// 6. Updated getUserAutoLoginTokenSilent() (line ~157) -Future getUserAutoLoginTokenSilent(...) async { - await reset(); // Was: reset() (synchronous) - // ... -} -``` - -**Impact:** -- Zero memory leaks in chat -- Stable performance over long sessions -- Better error visibility - ---- - -## πŸ“Š PERFORMANCE METRICS - -### Startup Performance: - -``` -Metric Before After Improvement -App Launch Time 2.5s 0.8s 68% faster βœ… -Provider Init 2.5s 0.2s 92% faster βœ… -Initial Memory 180 MB 85 MB 53% less βœ… -Splash to Dashboard 3.0s 1.0s 67% faster βœ… -``` - -### Runtime Performance: - -``` -Metric Before After Improvement -Dashboard Load 5.2s 1.5s 71% faster βœ… -Dashboard Rebuilds 20-30 5-8 71% fewer βœ… -Chat Rebuilds/min 50+ 10-15 80% fewer βœ… -Form Rebuilds 15-20 5-8 60% fewer βœ… -Frame Rate 45-55fps 58-60fps Consistent 60fps βœ… -``` - -### Network Performance: - -``` -Metric Before After Improvement -API Calls/Session 100 40-64 36-60% fewer βœ… -Lookup API Calls 40 4-8 80-90% fewer βœ… -JSON Parse Ops 200 100 50% fewer βœ… -Dropdown Load (1st) 500ms 150ms 70% faster βœ… -Dropdown Load (cache) 500ms 0ms 100% faster βœ… -Connection Overhead 150ms/call 0ms 100% faster βœ… -``` - -### Memory Management: - -``` -Metric Before After Improvement -Chat Memory Leak +5MB/sess +0MB Eliminated βœ… -Memory Growth (1hr) +80MB +20MB 75% less βœ… -Memory Stability Poor Excellent Fixed βœ… -``` - ---- - -## 🎯 CODE PATTERNS & BEST PRACTICES - -### Pattern 1: Lazy Provider Loading - -**When to Use:** -- ANY new provider that isn't needed immediately at app launch -- Module-specific providers (CM, PM, TM, etc.) -- Lookup providers -- Feature-specific providers - -**How to Implement:** -```dart -// In lib/main.dart providers list: -ChangeNotifierProvider(create: (_) => YourNewProvider(), lazy: true) -``` - -**When NOT to use lazy:** -- UserProvider (authentication) -- DashBoardProvider (immediate display) -- NotificationsProvider (background processing) -- SettingProvider (app configuration) - ---- - -### Pattern 2: Selector for Targeted Rebuilds - -**When to Use:** -- Widget only needs specific properties from provider -- Provider updates frequently -- Widget rebuild is expensive (lists, forms, complex UI) -- High-traffic screens - -**How to Implement:** - -**Simple case (single property):** -```dart -Selector( - selector: (_, provider) => provider.title, - builder: (context, title, child) => Text(title) -) -``` - -**Complex case (multiple properties):** -```dart -// 1. Create helper class -class _MySelectedData { - final String title; - final int count; - - const _MySelectedData({required this.title, required this.count}); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _MySelectedData && - title == other.title && - count == other.count; - - @override - int get hashCode => Object.hash(title, count); -} - -// 2. Use Selector with helper class -Selector( - selector: (_, provider) => _MySelectedData( - title: provider.title, - count: provider.count, - ), - builder: (context, data, child) => MyWidget( - title: data.title, - count: data.count, - ) -) -``` - -**When to use Consumer:** -- Widget needs entire provider state -- Provider rarely updates -- Simple, cheap widget rebuilds - ---- - -### Pattern 2A: Selector with Lists (CRITICAL) - -**⚠️ Important Lesson from Chat Message Fix:** - -When using Selector with Lists, the equality comparison must detect ALL changes, not just length. - -**❌ WRONG - Only compares length:** -```dart -class _MyListState { - final List items; - - const _MyListState({required this.items}); - - @override - bool operator ==(Object other) => - other is _MyListState && - items.length == other.items.length; // ❌ Misses in-place modifications! -} -``` - -**βœ… CORRECT - Compares length + identifying property:** -```dart -class _ChatMessagesState { - final List messages; - final int messageCount; - final DateTime? lastMessageTime; // πŸ”‘ KEY: Detects new messages - - _ChatMessagesState({required this.messages}) - : messageCount = messages.length, - lastMessageTime = messages.isNotEmpty ? messages.first.createdDate : null; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _ChatMessagesState && - messageCount == other.messageCount && - lastMessageTime == other.lastMessageTime; // βœ… Detects all changes - - @override - int get hashCode => Object.hash(messageCount, lastMessageTime); -} -``` - -**Why This Matters:** -- When list modified in-place (insert/remove), list reference stays same -- Length-only comparison can miss modifications -- Timestamp/ID comparison catches every change reliably - -**Best Practices for Lists:** -```dart -// βœ… Option 1: Length + first/last item property -lastMessageTime: messages.isNotEmpty ? messages.first.createdDate : null - -// βœ… Option 2: Length + item ID -lastItemId: messages.isNotEmpty ? messages.first.id : null - -// βœ… Option 3: If provider always creates new list -identical(items, other.items) // Only if new list instance every time - -// ❌ NEVER: Length only -items.length == other.items.length // Can miss in-place modifications -``` - -**Real-World Issue Fixed:** -- **Problem:** Chat messages not refreshing on receiver phone -- **Cause:** Selector compared length only, missed new text messages -- **Fix:** Added timestamp comparison β†’ now works perfectly -- **File:** `lib/modules/cx_module/chat/chat_page.dart` (lines 73-90) - ---- - -### Pattern 3: API Response Caching - -**When to Use:** -- Lookup/dropdown data (rarely changes) -- Static reference data -- Configuration data -- Any data that is same across multiple screens - -**How to Implement:** -```dart -// For lookup/dropdown APIs: -Response response = await ApiManager.instance.get( - URLs.yourLookupEndpoint, - useCache: true, // Cache for 1 hour - enableToastMessage: false, // No toast for lookups -); - -// For longer caching: -Response response = await ApiManager.instance.get( - URLs.staticData, - useCache: true, - cacheDuration: Duration(hours: 6), -); - -// To force refresh: -Response response = await ApiManager.instance.get( - URLs.lookup, - useCache: true, - forceRefresh: true, // Bypass cache -); -``` - -**When NOT to cache:** -- User-specific dynamic data -- Real-time data (chat messages, notifications) -- POST/PUT request responses -- Frequently changing data - ---- - -### Pattern 4: JSON Parsing Efficiency - -**Always Follow:** -```dart -// Parse once at the top -final responseBody = jsonDecode(response.body); - -// Then reuse throughout -if (responseBody is Map) { - final data = responseBody["data"]; - final message = responseBody["message"]; - final status = responseBody["status"]; - final error = responseBody["error"]; - // All from same parsed object -} -``` - -**Never Do:** -```dart -// Don't parse multiple times -final data = jsonDecode(response.body)["data"]; -final message = jsonDecode(response.body)["message"]; // Parsing again! -``` - ---- - -### Pattern 5: Connection Disposal - -**For Any Connection Resource:** -```dart -class MyProvider with ChangeNotifier { - Connection? _connection; - - // Safe disposal helper - Future _disposeConnection() async { - try { - if (_connection != null) { - await _connection!.close(); - } - } catch (e) { - if (kDebugMode) print('Error: $e'); - } finally { - _connection = null; // Always null - } - } - - // Always override dispose - @override - void dispose() { - _disposeConnection(); - super.dispose(); - } -} -``` - ---- - -## βœ… QUALITY CHECKLIST FOR NEW CODE - -### Before Committing Code: - -**Provider Management:** -- [ ] New providers use lazy: true (unless core) -- [ ] dispose() overridden if using resources -- [ ] Connections/controllers properly disposed -- [ ] No excessive notifyListeners calls - -**Widget Optimization:** -- [ ] High-frequency widgets use Selector -- [ ] Consumer only when entire provider needed -- [ ] Helper classes for multiple properties -- [ ] Proper equals and hashCode implementation - -**API Calls:** -- [ ] Lookup APIs use useCache: true -- [ ] JSON parsed only once per response -- [ ] ApiManager.instance used (not direct http) -- [ ] Toast messages disabled for lookups - -**Memory Safety:** -- [ ] dispose() overridden where needed -- [ ] finally blocks ensure cleanup -- [ ] Async disposal with error handling -- [ ] No fire-and-forget patterns - -**Code Quality:** -- [ ] Debug logging uses kDebugMode guards -- [ ] Optimization comments added -- [ ] Error handling present -- [ ] Follows established patterns - ---- - -## 🎯 VALIDATION PROMPT - -### USE THIS PROMPT FOR FUTURE CODE REVIEW: - ---- - -**COPY THIS ENTIRE PROMPT AND SEND TO AI:** - -``` -I have finished developing new features in the ATOMS Flutter application. Please analyze my changes to ensure they follow the performance optimization patterns established in April 2026. - -Reference document: ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md (in project root) - -Please perform the following validation: - -1. PROVIDER ANALYSIS: - - Check if new providers are lazy loaded (should have lazy: true) - - Verify dispose() methods are properly implemented - - Check for excessive notifyListeners() calls - - Confirm no new eager providers added (except if truly core) - -2. WIDGET REBUILD ANALYSIS: - - Find all new Consumer usage - - Identify if Selector should be used instead - - Check high-frequency widgets are optimized - - Verify helper classes have proper equals/hashCode - -3. API CALL ANALYSIS: - - Check if lookup/dropdown APIs use useCache: true - - Verify JSON is parsed only once per response - - Confirm ApiManager is used (not direct http calls) - - Check for double parsing anti-pattern - -4. MEMORY MANAGEMENT ANALYSIS: - - Verify all controllers are disposed - - Check connections are properly closed - - Confirm finally blocks used for cleanup - - Look for potential memory leaks - -5. CODE QUALITY ANALYSIS: - - Check debug logging uses kDebugMode - - Verify optimization patterns followed - - Look for fire-and-forget anti-patterns - - Check error handling is present - -6. PERFORMANCE REGRESSION ANALYSIS: - - Compare current metrics to baseline: - * App launch should be <1.5s - * Memory should be <120 MB - * Dashboard should load <2s - * Rebuilds should be <15 per interaction - - Identify any performance regressions - - Suggest fixes for any issues found - -7. PROVIDE DETAILED REPORT: - - List all issues found (if any) - - Rate severity (Critical/High/Medium/Low) - - Provide exact fix for each issue - - Show before/after code examples - - Estimate performance impact - -8. CREATE SUMMARY: - - Overall code quality grade - - Performance impact assessment - - Recommendations for improvement - - Approval status (Ready/Needs Fixes) - -Please be thorough and check ALL the patterns documented in ATOMS_OPTIMIZATION_COMPLETE_GUIDE.md. If you find any violations of optimization patterns, provide specific line-by-line fixes. - -Generate output as a markdown report with sections for each analysis area. -``` - ---- - -**END OF VALIDATION PROMPT** - ---- - -## πŸ“‹ HOW TO USE THE VALIDATION PROMPT - -### Step-by-Step Guide: - -**1. After Completing Your Development:** - - Finish coding your new features - - Test that functionality works - - Commit your changes locally - -**2. Copy the Validation Prompt:** - - Copy the entire prompt from the section above - - Include everything from "I have finished developing" to "Generate output as a markdown report" - -**3. Send to AI Assistant:** - - Open your AI assistant (GitHub Copilot, ChatGPT, etc.) - - Paste the entire prompt - - Wait for comprehensive analysis - -**4. Review the Analysis:** - - AI will generate a detailed report - - Check each section for issues - - Review severity ratings - - Read suggested fixes - -**5. Apply Fixes:** - - Implement any critical/high severity fixes - - Consider medium severity improvements - - Test after fixes - -**6. Re-validate if Needed:** - - If major fixes applied, run validation again - - Ensure all issues resolved - - Get final approval - -**7. Deploy with Confidence:** - - Once AI approves, deploy to production - - Monitor performance metrics - - Celebrate! πŸŽ‰ - ---- - -## πŸ” QUICK VALIDATION COMMANDS - -### Run These Before Using AI Prompt: - -```bash -# Navigate to project -cd "/Users/devsikander/StudioProjects/cloudsolutions-atoms copy" - -# Check compilation -flutter analyze lib/ - -# Count lazy providers (should be 107+) -grep -c "lazy: true" lib/main.dart - -# Check for new Consumer usage (review manually) -grep -r "Consumer<" lib/ --include="*.dart" | grep -v "// BEFORE" - -# Check caching usage (should be 15+) -grep -r "useCache: true" lib/ --include="*.dart" | wc -l - -# Check for double parsing (should be none) -grep -r "jsonDecode(response.body)" lib/controllers/api_routes/api_manager.dart | wc -l - -# Run tests -flutter test - -# Performance check -flutter run --profile -``` - -**If any command shows issues, note them for the AI validation.** - ---- - -## πŸ“Š PERFORMANCE BENCHMARKS - -### Target Values (Maintain These): - -``` -App Launch Time: <1.5s (Current: 0.8s) βœ… -Initial Memory: <120 MB (Current: 85 MB) βœ… -Dashboard Load: <2s (Current: 1.5s) βœ… -Widget Rebuilds: <15 (Current: 5-8) βœ… -API Calls (Lookups): <10 (Current: 4-8) βœ… -Dropdown (cached): <50ms (Current: 0ms) βœ… -Memory Leaks: 0 (Current: 0) βœ… -``` - -**Green Zone:** All metrics better than targets βœ… -**Yellow Zone:** Within 20% of targets 🟑 -**Red Zone:** Worse than targets by >20% πŸ”΄ (Needs immediate fix) - ---- - -## πŸš€ DEPLOYMENT CHECKLIST - -### Before Every Deployment: - -**1. Code Quality:** -- [ ] All new code follows optimization patterns -- [ ] No new eager providers added -- [ ] Selectors used where appropriate -- [ ] Caching enabled for lookups -- [ ] Proper disposal implemented - -**2. Performance:** -- [ ] App launches in <1.5s -- [ ] Memory usage <120 MB -- [ ] No visible lag in UI -- [ ] Dropdowns load quickly -- [ ] Chat is smooth - -**3. Testing:** -- [ ] All features work correctly -- [ ] No crashes or errors -- [ ] DevTools shows good metrics -- [ ] Memory stays stable over time - -**4. Validation:** -- [ ] Run validation commands -- [ ] Use AI validation prompt -- [ ] Fix any issues found -- [ ] Get final approval - ---- - -## πŸŽ“ TEAM TRAINING GUIDE - -### For New Developers: - -**Read These Sections First:** -1. Code Patterns & Best Practices -2. What NOT to Do -3. Quality Checklist - -**Key Concepts to Understand:** -- Why lazy loading matters (68% startup gain) -- When to use Selector vs Consumer (71% rebuild reduction) -- How caching works (60-80% fewer calls) -- Why proper disposal matters (no memory leaks) - -**Before Writing Code:** -- Review the patterns section -- Check the quality checklist -- Look at optimized files as examples - -**Before Committing Code:** -- Run validation commands -- Use AI validation prompt -- Fix any issues found - ---- - -## πŸ“š REFERENCE FILES - -### Key Implementation Files: - -**Provider Setup:** -- lib/main.dart (lines 207-318) - -**API Infrastructure:** -- lib/controllers/api_routes/api_manager.dart - -**Optimized UI Examples:** -- lib/dashboard_latest/widgets/requests_fragment.dart -- lib/modules/cx_module/chat/chat_page.dart -- lib/modules/asset_delivery_module/pages/asset_delivery_page.dart - -**Chat Stability:** -- lib/modules/cx_module/chat/chat_provider.dart - -**Cached Lookups:** -- lib/providers/lookups/*.dart -- lib/modules/*/provider/*lookup*.dart - ---- - -## 🎯 FINAL NOTES - -### Optimization Philosophy: - -**The 95/5 Rule:** -- 95% of value from 5% of optimizations -- Focus on critical bottlenecks -- Skip low-value polish -- Measure everything - -**What We Optimized:** -- βœ… Critical paths (startup, dashboard, chat) -- βœ… High-frequency operations (rebuilds, API calls) -- βœ… Known issues (memory leaks, double parsing) - -**What We Skipped:** -- ⏸️ Low-impact const widgets -- ⏸️ Rare code paths -- ⏸️ Over-engineering - -**Result:** 60-80% improvement in 2.4 hours! 🎯 - ---- - -### Maintaining Excellence: - -**Do:** -- βœ… Follow the patterns in this document -- βœ… Use the validation prompt regularly -- βœ… Monitor performance metrics -- βœ… Keep optimizations in mind - -**Don't:** -- ❌ Add eager providers without reason -- ❌ Use Consumer when Selector fits -- ❌ Skip caching for lookup data -- ❌ Parse JSON multiple times - ---- - -## πŸŽ‰ SUCCESS STORY - -### What Was Achieved: - -**In Just 2.4 Hours:** -- 17 files optimized -- 6 major optimization phases -- 68% faster startup -- 71% fewer rebuilds -- 60-80% fewer network calls -- Zero memory leaks -- Zero breaking changes - -**For Your Users:** -- πŸš€ Blazing fast app -- ✨ Smooth, responsive UI -- πŸ’¬ Lag-free chat -- πŸ“ Instant dropdowns -- πŸ”‹ Better battery life -- 😊 Professional experience - -**For Your Team:** -- πŸ“– Clear patterns established -- πŸ”§ Easy to maintain -- 🎯 Future-proof foundation -- πŸ“Š Measurable improvements -- βœ… Production-ready code - ---- - -## πŸ† FINAL STATUS - -**Implementation Status:** βœ… 100% COMPLETE -**Code Quality:** ⭐⭐⭐⭐⭐ Excellent -**Performance:** ⭐⭐⭐⭐⭐ Excellent -**Maintainability:** ⭐⭐⭐⭐⭐ Excellent -**Production Ready:** βœ… YES - -**Overall Grade:** **A+** πŸ† - ---- - -**Document Version:** 1.0 -**Last Updated:** April 12, 2026 -**Maintained By:** Development Team -**Next Review:** July 2026 (Quarterly) - -**This is your optimization bible - keep it updated and reference it often!** πŸ“–βœ¨ - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 70fa7d5b..4be07bc5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,16 @@ + + + + + + + + + + diff --git a/assets/audio/outgoing_ringtone.mp3 b/assets/audio/outgoing_ringtone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c1ed31f7339187c6443631211618a86309809656 GIT binary patch literal 69798 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

rCvs{o1RF5ny5Yc&95=nY$6`$-t zWN50H739`1koJz9M$3#ff%`+KV%uZhw=S^)T_1@*bM(BH5%j(8KGNnJOHouOj8~?X z^kSQZ-pyM7%?4sO!}go(k&|EUjn#w+5m4b z)c8xkDv4NVGUB>_?C?Icd8G=VhA!@M9=l3w){@rDQZ*i$CNq0hUt;YQP$uG zS2HxfkUOqXw7eE96QqvZmEmENPkKjvsH3l?7Y^zW1o?L$K-`KXOz8e7-Pj96^@7Wu zhqB=8RYFM|Z44*OdK4W9F(??n8yy7pK%vJNvjeH5q{us{jCzZumm8&+zP6I{j>sWP zPZ50D`(~TUkXZ`!NnczoM2n7m^$PaRMeF7fFSplIdg%PAm$|!BE`YU_yLyD@_MUJK zzJ)p19y4v8I!7-mauszYa7PdjlttDUs{jC!k}e7< z&@QXYH{QA@aFVDCbqQFJaC-2(Hc4Lxf5M)xY?y*T?m7zIqr~us#=&v!y~_KF1hm18 z7|A8Hh%rO{0gNq~TBr*e%Nv`4I^DIOAp&5)GSXu4S3|D6A|llWRhCh|UaQLZQI{)u7p_>*|GneOYhjUUc=?4sCLFMfJr)^YfOAVI(S_;*4?e+BUVc5VCRLEfhM{;(A6PInxM ziHY@5@ng*x5^fJs%Q=mEF>-2z(lWn4(%p$|Yoxk4-nY7Cp?Dq#Udw=zx!rWz)83Y?FUpxf@Wl{NPL>r)7y03v! zR3SjN<0HT(Sp)$7$v}!^J-{g`1_lwr7Zm6$A{uC=_8uDMZ^++4S=l0^)7q#Y(t6QNyw2ypMD>J{sznTE^CoBlZOa$~9BPgQ=^P=q_o0 z-{r2@N&mAWNd&SZ$xmAjC*E`I8L5UeLh|!4bmB5ls%gP~lg# zwUAX)Vc!r$KrF^dvx?h=G!NsL|SD%z9m%^y8rJsdoGXKl=xq|}Z!)@Fs@5(YsS zxX`1;Z#<=-6y1wXtEt6(v!-SGY-qL2hoDBq#KxMyb$i}?T(QQKDsfEdHsi|0+#W9v zrGe{TDxS3pOBl6;@eSlWFutiN%>5PWd`K5ESXiH&T?jnROqsEiOlrDe7Wd^v{nuF$ zo{?*@&m0B2>eq$R=6qjgA7qm=#WrRKHMI=6@#XXnU3a=#^_uwh^aQ-xNU) z_Zuw|o1ZlrNtA!$9I;sLd_(J$&yV(Dxi(=R6I*C>%eLphbbcN4X%qu zv?p2fBsT>6!uoS(<4zRc#W;U`{THE_V`+!3pgWRd-<>`b<~f*`e%9(QmDE?Lc&l4W zB)p)tBXL>%dbQ%(Cz-dqhHUO-r=utP)FpRslW3Wmsv<=FNf3UZ2Z-^219K;|HA}3> z7p@9|GdGv%60I9Cmn^VCvjpkhsZn!_be z5(mRslH|?J$8P&XaJo)JI1P`$rbSZJkez6Tbhx7brH;AC{v@I-FC$$ zwsbHxz)~>I!oA`1t%v_xAq2>u8G(WPRm%z1+l7V%By z6x>$8ifikEYQo)hM~Rw>DHRBw*&6T=H;%u7c){?kloBq9Xyy}w=j~KUPl9&a6lkMM zRC<~dR}Hc@Z>6{iS5}G8!WL8&=dB$Z79FcU8_};CxQz|gA>NDJD-QdyhL9oFpT#NU zezizzoBhWwcMrSr+Df@v9J?uA`%W-l;NojRT7r|+g=V2c`2*O3lbMP1R2WJc!rFfY zQHyk=i3F`!B;)YAh!rNge=6>=dJyLBBfIHr#q78n>z*iTn^$SBsD}Wd2R9CkcDA9t zkLWeB?d7Wvd>SR~y{Qdj)=X@B!@ zNY6|5>(AiGrgF?~eO&h@1?H>|n<=isEz;#NgsN@dEqi=r4KJ|SGS3NVd3FCf z+x5@aWOim{eY|JyZoE1AI(Ln{HRUKNDm!8Bzqk_qHn;&4e@af!O>9)=4pj8q4p}14 z{>I%0+rctp-J|Hfv-Et_dBdrrVpAH!{u77nxMtDsp1M{M-Q3EPBp+h~e1+>=M7j`- zB+NqkRGu3A_-{%Ltm4SjHk2a;Qsr7Lz}2 z+yeq%XBM^nMJVE!%J!dqm&j)`!B2m#p#YDhjUn@96+S0m&4~4IktpM4*|(`v>+Sms-gRKA;2BISY!+HEqpx-;aB+wnh5=mgnQ<0RL+4VUwc6& z>S?+q<2y9)u*iMl@dzuA9p_>ipH$zR*19*J{19!7W?HkErho;j(7n5Q10VijPJZCNGw(zPa;p>VbIXOX0!(no<#$ct>mHM>klt|FaLt z-+**EL6Qhm|7rvkiUHzMU>iaV$JJ8V-T&rfdY8pLf3lnFwKuK}{yF2*IuZ-&}mEKm5Xqjm=QBx^xif-&R zu&)~z56<|idA2sUJ8|zkRh7w_8p)0sTKoL@;rCsR#QvX&Bou~4;441~880RDUY2~z z@w(mT#+KTy6h&Y~roN+ggLbU?MQ822{^jJIh{amu4<;X(_`-=iJo5mh0Dd5{O99Fg zvMn&7V8+*PYylnbX%)3d7U-N20fR{*%)z(={+bK+I>}DdNF$>Tod1vU#loL%5*B)D8un}wy;NH zydP{tcXz3sOY7f#NPb#&$VQBp0ZdRZK+c#t9{A~u3`xQ;gh=Kj0yO3*0^WUH)+YMSmO88NvWF;#rG&SoE`6a2y#^&t;wYM{ z)>6EGa%JEhmpn0$$`a+n56b=c)iV!-4sY~PUNp~i5WXgS+BGKqgOE1TQut)}ql}eo zs{nlIXWDirEipdqnk}zIECA1PUX!XIxfETO0bPbHYTQvJ(!KH8T4zrEb7g%voAHon zvqc%a^=5D*e+%-br-w|2{e-Yawr6TbOExc$5O9E_Ra+)hVOsEG@w;Y@+Rs@yYH@pR z+t}UQFn2{~#;B5iL6fZV)pPcF-^@W#Nj*sL=IvJowQhO9fusp4`|=S&HcT-XPX=Wb z2RB?Aq)71Wo-96%=47i$47C4=ljOel#s;5rcHim_?X4RZFC}FQzu?`-wI{SQy+~u$ z&F1}vFZVTupS}1S3}dOVq*E6W?TKTrhZ0HyF1Jpya@vo(zWXYpBsd8toIz{kx?=Mw$+}a*XxkdkBoY(xC#vrcBG@`4vsi#3%8! z=)~?W)4puV!6x4v9t_?9U(&74l zKSF@>);MWit7=}Cec9J|XhTn4BtpnlgBztr{yG=Eom2K^Na!)Np|It?8!Zt&j(l8@ zY@^q5*o3SEVXV)bcooWb~3;rvp6i}Ff9c1xYAh2Ym}C$bO6 zb)LD&JA}jcA)kwe-+o|Fw)cA9_TF72C>2BT250GaLI(d~ZIg=?AZFfcbX~HvPw1Tz+hAbZZFT~g40C=8jW0_G%wm)p z{62I$8XNGQfIR7DgiShuHs%+cE*o5gQy-2HzYSI?#RiT)9wtdR?*`ZaoG;0Z4i@dcmL$>kP1l$DF&|FX{k*d6`=2-yyu0wo%Nb)x{<;rYPZMt<>+69>?~?yn+5 z{P6vo0)8pN5i?FS)8+eNK?nP{^G50<*H&!p=nh8fC@{ED9vSf{S2!`lz0%B(EoGU9_@ zxx{U3Y>l961Za>$&qYllesp!UyXY7a>HGL$oar<93xpD#sx+Hy{3Vgu7o9G9C(#h{kx6hg6-@DK?BH&GGK6aKYJzs0p_uf9@{CaOo&P_K$3X3#{aYpQ+()W7fAI+XV-Wy!FErI{APn?i_LvP;7^Ws-W zwb`t>$6rLljT4psT0?Qi$;ia|pUGHcw{pJ4hkopGw2wM7Kl>rN^kZ4us_K*NLzj7M z#l&r28Ot}VGVZ{)PuxwyT=n1X%YzVL8_*s>C|Wq}1{fqPPo|h$+~z9v>d<@4hT^$H zpFkz6brfsvg_0|M#gQQ^GT?Z~eRFpVdePuh%AJ$;aKa1|wi-irrtTT`YYb7#sU}=q zFdPxIh-I#v9>Ui%1(rrexykQ*t3RL6XT;Q8Ulq1*rJsz}$(^av=+WS6rB=Vay-LPL zvMS-bW|2;^?c%aZbNhI#GU)9=s}6}T@BO(KgI4YpjAa5&A_6Ow5ymRdG3S3++W&d_ z{2U>RyEXxAWRD&QstZ6W>jp?e{GqOoM8&18Dxrk}Gy$U_V<1C2e+5t7A<&QMs(CMQxGOJ?Z@ltD+R~2oomdXIGvoa%x=^a9Cp5p7tF&yZ13&zat&l@w2&$~ppT8p2Y`d= z0ZNrCPz%pfVF=?_C{Cy>aHs1Kc~O8aa1XQ%B|>&V_3OX@qs1GbxzmG)W5Ve!)>1v& zGL5XW@-Dku7e*FYQF~}^KfPjiSu3l!Bl$xLEnY-Rgt>86#NEWD9u*zF!gT93o_p)> zo(_iOd^VYu@c5rKbfW6eiS~~uxG=J-&(D@5Ut6OE?tC;(8|9M>;4=T}b6jBDx?Mn` zGR~yl`)Vq>D*x>;EpBwbZ&cftXi|(2;DN-GgjpQW1q3Qc13~WJhLA@IM=S(q*xe_* z;L9G*X}%vQYrlFcTCopdl9>~0Jx0Nf)0Rc`JdS@8!?rFiFc@lBryk-UD}#xxYQP-7 zyRJ;%o7ddvyD~z)o_v~eHV3UCeMVyc9p`&yp2Tgj2rON<~x+3caE zgWM3NfHA;V-OD)q#nJaQRAbW_^|M1GF|+`JO!fJ34yDc9@S%u6CZjK~xoAnIv%XmO zV3_C^pfABBl*v!e{*c6Q**ERYeX|%|6@O6>h#jRKRElDWN`tPAxkS`0=i6ktjzF^{ zC`Fkx4^oxCic=548%9AT687!TIR-Voe;1Z+Cw@hY$?9>%>s+7Y6Xx5G%!8`R9q}{S zb{=OnoJ8HSpI9znE0oXVYqM|O+tVN0e|5z#b2yrZ`_6ifxY}*Sw)fem?|ZX9?tFe1 z($<`Islo6CwAClpWcv`_`gG|z1hW4Af3?+qew+UHCjcIB5n%1i1(=X2r<^^5fH58( zjM=A=FKg=#5UD@_#o|q(*=vUNU}|duoG@cFe~s`6)S7sl3au$-a;@xo-@}Hi#qFvY zmAS50Og+?D=r^v|PNY(;PKUknt}SHRZdYymh%C5x+WeS*NDNq^CKkN<;5Do?zU}(! z#ldTU*Y|VC_FGc$-(60LQKBIDdwoC7A=`tOzIoQdZ%2g7?mNRIT<&Q-GiZ(x>J0e0 ze?a*p`r@Oy3n%lAsS{;+I$iCLR@L|$JwLW5p(Q!HS?S6d&tJks22U|5knx{1@DhZA z1bLxqFed*;0&R)U0~=8KP7ZzwcW8Vl)M=S-5DyB*z4T$tWv{0?`yKBUwJ)^o_agn) zjpV*!vF;cbHw)VU2QJ$JZ)TO;GY)1lpWHATvTc97pPFK|iEr_V@5G}S3Y*WfdHcMw zvl*l{QAh-o69R!moQ&iok@z6;Onuu=7{JTMxZsn_Zxr*9o1|1||=Tn_qW1@~@ z?x-9=s3dJpLPN|{Xd+TKlifKiAfP-;nVgajvCYTvzU3QC$K-7tY0 z3m$TzUzxKB4ynSYZz5ZB>V7!gE#ay7y!3L_>GxfZ5ZO-Q=PpN5guEZ>sQnLneNh6y4|!_1|l zzy~1ANJ~a$M3mjV=%b*Q7A*ON2CvU9sjP}3Ebj}IDnA8@dNaez7Gy?MP+e{* zT!naqj&Qg;k+i?Flf)A9c`JM5g>))G{t1i6ZFdmA0 zG6rlbUOloPkVrSCWCg#Sw)7#jPg^$%?OBlMX&X(U<%gR<8+G=XSZ(my%L13fSTa$1 z@L9Y5Z;ph2Y0=2k)z!jr2AVd{43_k?3LuX)))?V&513^RU7@l50fHZYbXeUZH zhsJxqva!DO7X_b-?+@I87g@QPGT+9=xsO`s&33@ZBl3Nh3$T|?`WXc$8PEG?QgGsI zLUM>_-@jOJOPs#l&$9wcM9Vo!LoD{en!f8bG+7yhEVQB`-+*BVRcsa%8UnuOC?bAJD zC|&ufI2E%_$Pvxw&h9j1agraCD4pbP zK|x!*pnwx&PcTZBEw#3e)~U#tR_uH)Q>2htGpM4UkoIVvW#z#LY|=xY^wx>96>&qJ z4%wAX$Okzl*X%E&VvMbSB{asJNFndG`SN#+m9#e+=#UXsb19zcLj8Ni6a z5DF}_{5T*d^9t*BxZ2IWv_T(wl* z(0N6pLc6$)KfPw4d&>_VDcU?|!dz63EfY#Dbi@(UQ|+nKz%C4rNbIII5?FmTBwn8q zvQ83dn_u#5C(HVIdNeYW z6IhtL`{2go!uJ~4bNVOqO}lMz0a3PjWsP6>dr0O0PEb3*(eVXw zH>8l= zwmnfY#kF?3@ig*VrBPH#UVB};t?!}~pR3+Kaw!0axYt zslwswE|JfKhd+Sd^0}GENCl9gF9#qnP6LU+gh8Gcl)}mA1KL!#pUi1}A`Dt$z>eM~ zyxU1x$FzS$SXLD*#2(3rp~+Dp;3g~=G%$VmIU)C*Ni>lSb03+szovzhMe1ejP;U;! zC8rGN8cdCGEJ)4z1IsmW8f%B)&4vEJ(c6uyV=RmxqgeY%#kE^-VgxRwG(K9Zo+q?Z z?MY~sXe50VmXySv%v5(h1mnx7b6;Vv^eEhn=7>BpbQx0S{d_IO$Vn~yt*ld>_n_vY z`}f`N;r|>*$gV!+0K5Mx07y9iI>;2%!u}%|dRv|Nt0;EpUf@;nmL)|54wCtR=g? zs5@?`u*yGwj852&J8CFgo8r8E+?1U%FsP;ExlNuaoLM%N9>x(X-5;b64@a1wm?7)~ zauJEBWajw6^cK3{)N9l&qAn(qZRV%pqW1YzR)&Dn1exD3y5kd73rS>+Aaj&61(jmB z=CRZjUdiAsFI@bTh<9N!&J^Q`9*l%5gm)QbU?@tIR;ovHfWh&J@i0}-tc~+sMQf&y zOPf!wziuU%DQ=p1TC3e!+8-#anc--j(9&O?oqc@OnkzV^5AZ1w(OTzIEpcj`c4n^A>dCn(PulmLY$kl~ps49oGSo79?Xq+dCYDkfw6!+5Fs2(p& zdd!g-!6IKwq=Ma$vMgeyBBjJh9y)xRbZj%vGv=XZe2Z`W=);lv-5K}`m@zeN>B*r- zjqdkjDDj))^yeB1DEX&b4!$E^%9foPzuOzXbIPY5JY-i{HK2@#y5KnHC$F`tOz+`q z*S3wu>lBTv!5e*H39d@&ssv#Hc?@felf@iiUQ_jdwjOPV0#fS~iPn-B@v)4{_8M^o zhUKvQJD&Jv8j>=tu2l4S$Z2j}5%0C1Y-+@2zKxTbQ8b3jIxuq|QSQI!aavWN_^RR> z-1%v`{*fbBOP64ybotuTIjrZ-vW1UYR(KXhZDF4u9iK|$-rB*_p>Rww_8D}qdbBBU zJN>vVws@>=?lPQ8W`N^%fWrrEs`tk3>U2s(RLV{3TQPCEVO*E04MknPTjVbPuoKFW z*9q7G)cz3w8?u?U2+BjiyLS_6-g;g1M%W=V8-yl-Q#k5EjJg=ZI{+p0?N~-XwjDEB za_uUN&N7sRx^)GO2a6&UcmvTX@X;+7G}146xX5XC@m9xRMJ0-tP-A0!HM^XA+g9m~ z0wv$>#Ja4jb z_kxuG;kpN`NwyA5vDCqWW!G7X?ChfpZh3!T$$ep%^8)V{BX1c$ zG(7gt(EWtIHei88ro(Gz>NaUZ!p35db*eS$Ttp%6S8vs2a5F*Z%U4vd5LVLR(8;H>)F4UP zY@lWj^w`hIyx^_0R_U9lj{+)7wQ4WX=$UeEZR7aaeH$P^hKC>>!2p*(DS+A;3W#IK z0q|S(BKBjWz(9w;1c_ufs|Hn| zW){qlz(SuI+}A%vkIb;v)?JjXdc2tAwuLqC`f3H#rWuIsskxdlrj>s)y=U@LJ_|3L z$1UI2ZSwmr2iSZI;Qa^>QC1ZoJCbloes^uV%Go9B!wPU!+*fQq2F+$GdbD} z<31WeqZcT#dIQ$R4vO~*lWNufm_UVvGr9eyt4N@z6x0LCn$ptUHz1+fi%m^bci0 zsjG$zWv$+W##)vyZ6%3*43wQWo*fU0X8&@io2ub4)@&H7-#@+A>F$PPh0-B zbiN9KDV>jnvWkFDnvau znHk6dN}{G_%LmhfK_>2=K@AbRJcs@(mf-_+o~{=D*1f%hxn4YW_@ufG*!)&NR3jjdVsPsDlMd{;OA$k^*@DncVo+tM9 zsEo?)Ea{r)D}%4^ar;R2?j21!kO<7EvCULH~lIT&28@w#fF_(xV_^>Am$r%oU z>za}jh!hirBIK3V8TJj05*yf9f)Gy1dckTm3YyGe=8EAo!5z1PtzCF7$ZqIV@kS~s zHXannsu42>2iGk=`ec$i&B9=BHEqc}&RAHap~RXXz+qh`(8fsb>!k};PT~%}j;FMi z*j+>y;m0v=(bOkfxLfYNqC(DK&$Kgc!K6Sd3*DUYv1ekDe5I8s{!u3|t~$z?^Pb{1 z1p@^LW%@EDcBg)pJ6Yra5Y{BT&CpQA_dL+uA)Tw7&b4p5`kwT7r3<8|)2vV<-a@t~ zgq<6<#rAz35j2PBFQtz3cty13Mx!f&4oo5jzv;& zTS=pfA8QL{Rjt5`rUmDdlrySwtF{KQC26CYr@AqKIHjuP-Swsia%i}Rh3eueOhL3T48=z8NQI=Py&M*hbC5n zpR}et3ahZb+D;IMYD2aSZ+@4oSIAhtA6z~@uzR3UKv6Y+)8G6+w=fgWg0$F>qNs~u z#ae||IV7yt<|)X^NgW)_YLp0|1~fTX#|Y`Vt8OwbYw?W)veA?W&ji;&IdGI}#tDy@ zSvsEdJonB|C9#(_SLDW1=nIuh*PgKK>2uq&(*@*~CYMiS*Stozm(-FkaB)~W2}dh2`N=uBnV%d-{@%F1D* z(PuOhF|rq|dRVQh!M^<)6%{p-USex8D1J~}xJ)l8J7$!~2>C{6Kj!$EU0Xt&TKo?} zwD}vd4+Z?c?{dtLp6QkZ;#hIsOPp$sG0uYpM&&_-zvgECy>tKn>*0)J>i_*8`;E{^ z+!2RWRrnKl;CDjE8}AE2AQ1g`LjUij_=Eq4KZ4NLIHZYSk3<4KC-kr1$~mEbspJoP zaZczD1M)9%&I$cXC4bn9b3%U@kbjADPUv4M`NLkE6Z*q|{7am3LjO|9ANJy$&>sfm zU*en-`j<-nuovfq{xBf_66c)Izf|&vy*MZIhXMIjoZk)znMs1||99>={T0mL} z(KW%(Y=uac^t32m~e$~jIHa;ixrxE#8o^wLKYUEEFpA-7ii2N$gIiX)Q z@~4f@3H@nAewF8((61W#)5hn7{xl-L%5zTWSB?B><8wlP8j)Y+IVbe1M*g(%IiWv| z$glF86Z%ynf70H)?bGLkemjCQ1UM&jhJt?k z^f{s5j^GRd&Iz5Npx-`yPUyEII75JQLT4!Gw@;rF`t1nL5a68984CK{)4v_idAi&g zKY`y)=sc0+oX{B>_|vD)*U&kkGc<6%%blU1^EGsaf`0q-IicT<;0yuI37w&!-#&d# z=(i&{Lx6KaXDH~mPoER|?Fh~g;GEDI3i|ES=Y)Pcf-?j-Cv=8_e*5$}q2G?+3<1sw houQ!LK7CH;w<9=1fOA4;DCoCOpA-7+2>x^d{tp9`WI6x< literal 0 HcmV?d00001 diff --git a/debug_video_call.sh b/debug_video_call.sh new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/debug_video_call.sh @@ -0,0 +1 @@ + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8792c07d..af6abd60 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,6 +66,8 @@ fetch remote-notification + voip + audio UILaunchStoryboardName LaunchScreen diff --git a/lib/modules/cx_module/chat/call/audio_call_page.dart b/lib/modules/cx_module/chat/call/audio_call_page.dart index 5db3c0b0..bc3cf70e 100644 --- a/lib/modules/cx_module/chat/call/audio_call_page.dart +++ b/lib/modules/cx_module/chat/call/audio_call_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; @@ -8,6 +10,7 @@ import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; import 'package:test_sa/modules/cx_module/chat/model/call_session.dart'; import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/app_style/app_themes.dart'; import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; class AudioCallPage extends StatefulWidget { @@ -21,12 +24,35 @@ class _AudioCallPageState extends State { @override void initState() { super.initState(); - // Initialize call on page load + // Listen for call end and navigate back WidgetsBinding.instance.addPostFrameCallback((_) { - // Will be implemented when connecting to provider + final chatProvider = context.read(); + // Add listener to detect when call ends + chatProvider.addListener(_checkCallStatus); }); } + void _checkCallStatus() { + final chatProvider = context.read(); + // If call status is idle (call ended), navigate back to chat + if (chatProvider.callStatus == CallStatus.idle && mounted) { + // Remove listener before popping to avoid memory leaks + chatProvider.removeListener(_checkCallStatus); + Navigator.of(context).pop(); + } + } + + @override + void dispose() { + // Clean up listener + try { + context.read().removeListener(_checkCallStatus); + } catch (e) { + // Provider might already be disposed + } + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -58,31 +84,86 @@ class _AudioCallPageState extends State { ), ); } + + Widget _buildTopBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + const Spacer(), + Text( + 'Audio Call', + style: AppTextStyles.heading6.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + const SizedBox(width: 48), // Balance the back button + ], + ), + ); + } + Widget _buildContactInfo(BuildContext context, CallSession session) { return Column( children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColor.whiteF8d, - //TODO need to check opacity - border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1), - ), - child: ClipOval( - child: session.peerAvatar != null - ? CachedNetworkImage( - imageUrl: session.peerAvatar!, - fit: BoxFit.cover, - placeholder: (context, url) => Center( - child: CircularProgressIndicator( - color: Colors.white.withOpacity(0.5), - ), + Stack( + alignment: Alignment.center, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.whiteF8d, + //TODO need to check opacity + border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1), + ), + child: ClipOval( + child: session.peerAvatar != null + ? CachedNetworkImage( + imageUrl: session.peerAvatar!, + fit: BoxFit.cover, + placeholder: (context, url) => Center( + child: CircularProgressIndicator( + color: Colors.white.withOpacity(0.5), + ), + ), + errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ) + : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ), + ), + // Show mute indicator when peer is muted + Selector( + selector: (_, provider) => provider.isPeerMuted, + builder: (context, isPeerMuted, _) { + if (!isPeerMuted) return const SizedBox.shrink(); + + return Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColor.red30, + shape: BoxShape.circle, + border: Border.all(color: AppColor.white10, width: 2), ), - errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), - ) - : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), - ), + child: 'mic_disable'.toSvgAsset( + width: 16, + height: 16, + color: AppColor.white10, + ), + ), + ); + }, + ), + ], ), 24.height, Text( @@ -93,6 +174,44 @@ class _AudioCallPageState extends State { ), textAlign: TextAlign.center, ), + // Show "Microphone is off" text when peer is muted + Selector( + selector: (_, provider) => provider.isPeerMuted, + builder: (context, isPeerMuted, _) { + if (!isPeerMuted) return const SizedBox.shrink(); + + return Column( + children: [ + 8.height, + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColor.red30.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + 'mic_disable'.toSvgAsset( + width: 14, + height: 14, + color: AppColor.white10, + ), + 6.width, + Text( + 'Microphone is off', + style: AppTextStyles.bodyText2.copyWith( + color: AppColor.white10, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ); + }, + ), ], ); } @@ -177,20 +296,20 @@ class _AudioCallPageState extends State { ); }, ), - _buildControlButton( - icon: 'video_call_icon', - label: 'Video', - isActive: false, - onPressed: () { - // Switch to video call view - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => const VideoCallPage(), - ), - ); - }, - ), + // _buildControlButton( + // icon: 'video_call_icon', + // label: 'Video', + // isActive: false, + // onPressed: () { + // // Switch to video call view + // Navigator.pushReplacement( + // context, + // MaterialPageRoute( + // builder: (context) => const VideoCallPage(), + // ), + // ); + // }, + // ), Selector( selector: (_, provider) => provider.isSpeakerOn, builder: (context, isSpeakerOn, _) { @@ -254,9 +373,7 @@ class _AudioCallPageState extends State { child: 'end_call'.toSvgAsset(height: 32, width: 32, color: AppColor.white10), ).onPress(() async { await provider.hangUp(); - if (mounted) { - Navigator.pop(context); - } + // Removed Navigator.pop() - automatic listener will handle navigation }); } -} +} \ No newline at end of file diff --git a/lib/modules/cx_module/chat/call/call_debug_helper.dart b/lib/modules/cx_module/chat/call/call_debug_helper.dart new file mode 100644 index 00000000..2cc1383f --- /dev/null +++ b/lib/modules/cx_module/chat/call/call_debug_helper.dart @@ -0,0 +1,92 @@ +import 'dart:developer'; +import 'package:flutter/foundation.dart'; + +/// Helper class to debug call issues +class CallDebugHelper { + static void logCallInitiation({ + required String callerEmployeeNumber, + required String calleeEmployeeNumber, + required bool isVideo, + required String hubConnectionState, + }) { + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('πŸ“ž INITIATING CALL', name: 'CALL_DEBUG'); + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('Caller: $callerEmployeeNumber', name: 'CALL_DEBUG'); + log('Callee: $calleeEmployeeNumber', name: 'CALL_DEBUG'); + log('Type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'CALL_DEBUG'); + log('Hub State: $hubConnectionState', name: 'CALL_DEBUG'); + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + } + + static void logConnectionStatus({ + required String employeeNumber, + required bool isConnected, + required bool handlersRegistered, + String? additionalInfo, + }) { + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('πŸ“‘ CONNECTION STATUS CHECK', name: 'CALL_DEBUG'); + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('Employee: $employeeNumber', name: 'CALL_DEBUG'); + log('Connected: $isConnected', name: 'CALL_DEBUG'); + log('Handlers Registered: $handlersRegistered', name: 'CALL_DEBUG'); + if (additionalInfo != null) { + log('Info: $additionalInfo', name: 'CALL_DEBUG'); + } + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + } + + static void logEventReceived({ + required String eventName, + required dynamic rawData, + required String currentStatus, + }) { + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('πŸ“¨ SIGNALR EVENT RECEIVED', name: 'CALL_DEBUG'); + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + log('Event: $eventName', name: 'CALL_DEBUG'); + log('Current Status: $currentStatus', name: 'CALL_DEBUG'); + log('Raw Data: $rawData', name: 'CALL_DEBUG'); + log('═══════════════════════════════════════════', name: 'CALL_DEBUG'); + } + + static void printCallTroubleshooting() { + if (kDebugMode) { + print('\n'); + print('╔════════════════════════════════════════════════════════════╗'); + print('β•‘ CALL NOT RECEIVED - TROUBLESHOOTING β•‘'); + print('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•'); + print(''); + print('Check the following on BOTH devices:'); + print(''); + print('1. SignalR Connection:'); + print(' Look for: "πŸ”Œ SignalR Hub Connection: Started"'); + print(' Look for: "βœ… All call handlers registered successfully"'); + print(''); + print('2. Employee Numbers:'); + print(' Verify sender and recipient employee numbers are correct'); + print(' Look for: "πŸ”΅ [CALL] Sender: XXX"'); + print(' Look for: " - target: XXX"'); + print(''); + print('3. Backend Response:'); + print(' On receiving device, look for:'); + print(' "πŸ“ž [CALL EVENT] OnIncomingCallAsync received"'); + print(''); + print('4. Common Issues:'); + print(' ❌ Receiving device not connected to SignalR'); + print(' ❌ Call handlers not registered (app not in chat screen)'); + print(' ❌ Wrong employee number in backend mapping'); + print(' ❌ Backend feature flag disabled for audio/video calls'); + print(' ❌ Network connectivity issues'); + print(''); + print('5. Backend Logs:'); + print(' Check backend logs for CallUserAsync invocation'); + print(' Verify backend is routing to correct user connection'); + print(''); + print('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•'); + print('\n'); + } + } +} + diff --git a/lib/modules/cx_module/chat/call/call_error_handler.dart b/lib/modules/cx_module/chat/call/call_error_handler.dart new file mode 100644 index 00000000..54c93abf --- /dev/null +++ b/lib/modules/cx_module/chat/call/call_error_handler.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart'; +import 'package:test_sa/new_views/swipe_module/dialoge/info_dialog.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'dart:io'; + +/// Centralized error handler for call-related errors +class CallErrorHandler { + /// Show permission denied dialog + static void showPermissionDenied( + BuildContext context, { + required String permissionName, + VoidCallback? onSettingsTap, + }) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => InfoDialog( + title: 'Permission Required', + message: '$permissionName permission is required to make calls.', + content: [ + InfoContent( + title: 'Action needed', + message: 'Please grant $permissionName permission in Settings to continue.', + ), + ], + okTitle: 'Open Settings', + onTap: () { + Navigator.pop(context); + openAppSettings(); + onSettingsTap?.call(); + }, + ), + ); + } + + /// Show microphone permission denied + static void showMicrophonePermissionDenied(BuildContext context) { + showPermissionDenied( + context, + permissionName: 'Microphone', + ); + } + + /// Show camera permission denied + static void showCameraPermissionDenied(BuildContext context) { + showPermissionDenied( + context, + permissionName: 'Camera', + ); + } + + /// Show user is busy dialog + static void showUserBusy(BuildContext context, String userName) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'User Busy', + message: '$userName is currently on another call. Please try again later.', + okTitle: 'OK', + ), + ); + } + + /// Show call declined dialog + static void showCallDeclined(BuildContext context, String userName, String? reason) { + String message; + switch (reason) { + case 'declined': + message = '$userName declined your call.'; + break; + case 'busy': + message = '$userName is busy on another call.'; + break; + case 'no answer': + message = '$userName didn\'t answer the call.'; + break; + default: + message = 'Call was not answered.'; + } + + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'Call Ended', + message: message, + okTitle: 'OK', + ), + ); + } + + /// Show connection failed dialog + static void showConnectionFailed(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Connection Failed', + message: 'Unable to establish call connection. Please check your internet connection and try again.', + okTitle: 'OK', + ), + ); + } + + /// Show WebRTC initialization failed + static void showWebRTCInitFailed(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Call Setup Failed', + message: 'Failed to initialize call. Please check your device settings and try again.', + okTitle: 'OK', + ), + ); + } + + /// Show SignalR not connected + static void showSignalRNotConnected(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Connection Error', + message: 'Not connected to server. Please check your internet connection.', + okTitle: 'OK', + ), + ); + } + + /// Show ICE connection failed + static void showICEConnectionFailed(BuildContext context) { + showDialog( + context: context, + builder: (context) => InfoDialog( + title: 'Connection Issue', + message: 'Unable to establish peer-to-peer connection.', + content: [ + InfoContent( + title: 'Possible causes', + message: 'Network firewall, poor internet connection, or restricted network.', + ), + InfoContent( + title: 'Suggestion', + message: 'Try switching to a different network (WiFi/Mobile data).', + ), + ], + okTitle: 'OK', + ), + ); + } + + /// Show call timeout dialog + static void showCallTimeout(BuildContext context, String userName) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'No Answer', + message: '$userName didn\'t answer the call.', + okTitle: 'OK', + ), + ); + } + + /// Show network error during call + static void showNetworkErrorDuringCall(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const SingleBtnDialog( + title: 'Connection Lost', + message: 'Network connection was lost during the call.', + okTitle: 'OK', + ), + ); + } + + /// Show generic error + static void showGenericError(BuildContext context, String message) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'Error', + message: message, + okTitle: 'OK', + ), + ); + } + + /// Show call already in progress + static void showCallAlreadyInProgress(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Call In Progress', + message: 'You are already in a call. Please end the current call before starting a new one.', + okTitle: 'OK', + ), + ); + } + + /// Show user offline + static void showUserOffline(BuildContext context, String userName) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'User Offline', + message: '$userName is currently offline and cannot receive calls.', + okTitle: 'OK', + ), + ); + } + + /// Show reconnecting dialog + static void showReconnecting(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const PopScope( + canPop: false, + child: Dialog( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(width: 24), + Text('Reconnecting...'), + ], + ), + ), + ), + ), + ); + } + + /// Check internet connectivity + static Future hasInternetConnection() async { + try { + final result = await InternetAddress.lookup('google.com'); + return result.isNotEmpty && result[0].rawAddress.isNotEmpty; + } on SocketException catch (_) { + return false; + } + } + + /// Show no internet connection dialog + static void showNoInternetConnection(BuildContext context) { + showDialog( + context: context, + builder: (context) => InfoDialog( + title: 'No Internet Connection', + message: 'Please check your internet connection and try again.', + content: [ + InfoContent( + title: 'Action needed', + message: 'Make sure you are connected to WiFi or mobile data.', + ), + ], + okTitle: 'OK', + ), + ); + } + + /// Show poor network quality warning + static void showPoorNetworkQuality(BuildContext context) { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => InfoDialog( + title: 'Poor Network Quality', + message: 'Your network connection is weak. Call quality may be affected.', + content: [ + InfoContent( + title: 'Recommendation', + message: 'Try moving to an area with better signal or connect to WiFi.', + ), + ], + okTitle: 'Continue Anyway', + ), + ); + } + + /// Show server error dialog + static void showServerError(BuildContext context, {String? errorMessage}) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'Server Error', + message: errorMessage ?? 'Unable to reach the server. Please try again later.', + okTitle: 'OK', + ), + ); + } + + /// Show call initialization timeout + static void showCallInitializationTimeout(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Connection Timeout', + message: 'Call setup is taking too long. Please check your connection and try again.', + okTitle: 'OK', + ), + ); + } + + /// Show media device error (camera/microphone) + static void showMediaDeviceError(BuildContext context, String deviceName) { + showDialog( + context: context, + builder: (context) => InfoDialog( + title: 'Device Error', + message: 'Unable to access your $deviceName.', + content: [ + InfoContent( + title: 'Possible causes', + message: 'Another app may be using the $deviceName, or the device may be unavailable.', + ), + InfoContent( + title: 'Solution', + message: 'Close other apps using the $deviceName and try again.', + ), + ], + okTitle: 'OK', + ), + ); + } + + /// Show call ended unexpectedly + static void showCallEndedUnexpectedly(BuildContext context, {String? reason}) { + showDialog( + context: context, + builder: (context) => SingleBtnDialog( + title: 'Call Ended', + message: reason ?? 'The call ended unexpectedly. This may be due to network issues.', + okTitle: 'OK', + ), + ); + } + + /// Show peer connection lost + static void showPeerConnectionLost(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Connection Lost', + message: 'Lost connection to the other participant. The call has ended.', + okTitle: 'OK', + ), + ); + } + + /// Check and handle internet connectivity before call + static Future checkInternetConnectionForCall(BuildContext context) async { + final hasInternet = await hasInternetConnection(); + if (!hasInternet) { + showNoInternetConnection(context); + return false; + } + return true; + } + + /// Show backend not reachable error + static void showBackendNotReachable(BuildContext context) { + showDialog( + context: context, + builder: (context) => InfoDialog( + title: 'Server Unreachable', + message: 'Unable to connect to the server.', + content: [ + InfoContent( + title: 'Please check', + message: 'Your internet connection and try again. If the problem persists, the server may be down.', + ), + ], + okTitle: 'OK', + ), + ); + } + + /// Show SignalR disconnected during call + static void showSignalRDisconnected(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const SingleBtnDialog( + title: 'Connection Lost', + message: 'Lost connection to the server. The call has ended.', + okTitle: 'OK', + ), + ); + } + + /// Show call setup timeout error + static void showCallSetupTimeout(BuildContext context) { + showDialog( + context: context, + builder: (context) => const SingleBtnDialog( + title: 'Setup Failed', + message: 'Call setup timed out. Please check your connection and try again.', + okTitle: 'OK', + ), + ); + } + + /// Handle WebRTC media error with specific message + static void handleMediaError(BuildContext context, dynamic error) { + final errorString = error.toString().toLowerCase(); + + if (errorString.contains('permission') || errorString.contains('denied')) { + if (errorString.contains('camera') || errorString.contains('video')) { + showCameraPermissionDenied(context); + } else { + showMicrophonePermissionDenied(context); + } + } else if (errorString.contains('notfound') || errorString.contains('not found')) { + showMediaDeviceError(context, 'device'); + } else if (errorString.contains('notreadable') || errorString.contains('in use')) { + showMediaDeviceError(context, 'camera or microphone'); + } else { + showWebRTCInitFailed(context); + } + } + + /// Handle ICE connection error + static void handleICEConnectionError(BuildContext context) { + showICEConnectionFailed(context); + } + + /// Handle unexpected error with fallback message + static void handleUnexpectedError(BuildContext context, dynamic error, {String? customMessage}) { + final message = customMessage ?? 'An unexpected error occurred. Please try again.'; + showGenericError(context, message); + } + + /// Validate and show appropriate error for call failure + static Future validateCallPreconditions( + BuildContext context, { + required bool checkInternet, + required bool checkPermissions, + required bool isVideoCall, + }) async { + // Check internet connection + if (checkInternet) { + final hasInternet = await hasInternetConnection(); + if (!hasInternet) { + showNoInternetConnection(context); + return false; + } + } + + // Check microphone permission + if (checkPermissions) { + final micStatus = await Permission.microphone.status; + if (!micStatus.isGranted) { + showMicrophonePermissionDenied(context); + return false; + } + + // Check camera permission for video calls + if (isVideoCall) { + final cameraStatus = await Permission.camera.status; + if (!cameraStatus.isGranted) { + showCameraPermissionDenied(context); + return false; + } + } + } + + return true; + } +} diff --git a/lib/modules/cx_module/chat/call/incoming_call_dialog.dart b/lib/modules/cx_module/chat/call/incoming_call_dialog.dart new file mode 100644 index 00000000..e305ec40 --- /dev/null +++ b/lib/modules/cx_module/chat/call/incoming_call_dialog.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/string_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; +import '../chat_provider.dart'; +import '../model/call_session.dart'; +import 'audio_call_page.dart'; +import 'video_call_page.dart'; + +/// Native-style full-screen incoming call dialog matching audio call page design +class IncomingCallDialog extends StatefulWidget { + final CallSession call; + + const IncomingCallDialog({ + Key? key, + required this.call, + }) : super(key: key); + + static void show(BuildContext context, CallSession call) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => IncomingCallDialog(call: call), + ), + ); + } + + @override + State createState() => _IncomingCallDialogState(); +} + +class _IncomingCallDialogState extends State { + @override + void initState() { + super.initState(); + // Listen for call status changes + WidgetsBinding.instance.addPostFrameCallback((_) { + final chatProvider = context.read(); + chatProvider.addListener(_checkCallStatus); + }); + } + + void _checkCallStatus() { + if (!mounted) return; // Early exit if widget is unmounted + + final chatProvider = context.read(); + // If call status is idle (call cancelled/ended), automatically dismiss dialog + if (chatProvider.callStatus == CallStatus.idle) { + // Remove listener before popping + chatProvider.removeListener(_checkCallStatus); + Navigator.of(context).pop(); + } + } + + @override + void dispose() { + // Clean up listener + try { + context.read().removeListener(_checkCallStatus); + } catch (e) { + // Provider might already be disposed + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final chatProvider = context.read(); + + return PopScope( + canPop: false, // Prevent dismissing by back button + child: Scaffold( + backgroundColor: AppColor.backgroundTabBarDark, + body: Column( + children: [ + const Spacer(flex: 2), + _buildContactInfo(context, widget.call), + 8.height, + _buildIncomingCallStatus(context), + const Spacer(flex: 4), + _buildActions(context, chatProvider, widget.call), + 32.height, + ], + ), + ), + ); + } + + Widget _buildContactInfo(BuildContext context, CallSession session) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.whiteF8d, + border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1), + ), + child: ClipOval( + child: session.peerAvatar != null + ? CachedNetworkImage( + imageUrl: session.peerAvatar!, + fit: BoxFit.cover, + placeholder: (context, url) => Center( + child: CircularProgressIndicator( + color: Colors.white.withOpacity(0.5), + ), + ), + errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ) + : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ), + ), + 24.height, + Text( + session.peerName, + style: AppTextStyles.heading2.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildIncomingCallStatus(BuildContext context) { + return Column( + children: [ + Text( + widget.call.type == CallType.video ? 'Atoms Video Call' : 'Atoms Audio Call', + style: AppTextStyles.heading5.copyWith( + color: AppColor.neutral100, + fontWeight: FontWeight.w400, + ), + ), + 8.height, + Text( + 'Incoming call...', + style: AppTextStyles.heading5.copyWith( + color: AppColor.neutral100, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } + + Widget _buildActions(BuildContext context, ChatProvider chatProvider, CallSession session) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Decline button + _buildActionButton( + icon: 'end_call', + label: 'Decline', + onPressed: () async { + Navigator.of(context).pop(); + await chatProvider.declineCall('declined'); + }, + ), + + // Accept button + _buildActionButton( + icon: 'calling_icon', + label: 'Accept', + onPressed: () async { + // Just dismiss the dialog - acceptCall() will handle navigation + Navigator.of(context).pop(); + await chatProvider.acceptCall(); + }, + ), + ], + ); + } + + Widget _buildActionButton({ + required String icon, + required String label, + required VoidCallback onPressed, + }) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: icon == 'end_call' ? AppColor.red30 : Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + alignment: Alignment.center, + child: icon.toSvgAsset(height: 32, width: 32, color: AppColor.white10), + ).onPress(onPressed), + 8.height, + Text( + label, + style: AppTextStyles.heading5.copyWith( + color: AppColor.neutral100, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/lib/modules/cx_module/chat/call/services/callkit_service.dart b/lib/modules/cx_module/chat/call/services/callkit_service.dart index e69de29b..7b44560c 100644 --- a/lib/modules/cx_module/chat/call/services/callkit_service.dart +++ b/lib/modules/cx_module/chat/call/services/callkit_service.dart @@ -0,0 +1,386 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:uuid/uuid.dart'; + +/// Service to handle CallKit (iOS) and ConnectionService (Android) integration +/// Provides native call UI experience on both platforms +class CallKitService { + static final CallKitService _instance = CallKitService._internal(); + factory CallKitService() => _instance; + CallKitService._internal(); + + // Callbacks for call events + Function(String callId)? onCallAccepted; + Function(String callId)? onCallDeclined; + Function(String callId)? onCallEnded; + Function(String callId)? onCallTimeout; + + // Stream subscription for CallKit events + StreamSubscription? _eventSubscription; + + // Current active call UUID + String? _currentCallId; + + /// Initialize CallKit service and listen for events + Future initialize() async { + if (kDebugMode) { + log('πŸ“ž [CallKit] Initializing CallKit service', name: 'CallKitService'); + } + + // Listen for CallKit events + _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_handleCallKitEvent); + + if (kDebugMode) { + log('βœ… [CallKit] Service initialized', name: 'CallKitService'); + } + } + + /// Handle CallKit events from native side + void _handleCallKitEvent(CallEvent? event) { + if (event == null) return; + + if (kDebugMode) { + log('πŸ“ž [CallKit] Event received: ${event.toString()}', name: 'CallKitService'); + log('πŸ“ž [CallKit] Event type: ${event.eventName}', name: 'CallKitService'); + } + + // Handle different event types using pattern matching + switch (event) { + case CallEventActionCallAccept(:final callKitParams): + // User accepted the call via CallKit UI + final callId = callKitParams.id; + if (kDebugMode) { + log('βœ… [CallKit] Call ACCEPTED via native UI - calling callback', name: 'CallKitService'); + log(' Call ID: $callId', name: 'CallKitService'); + } + onCallAccepted?.call(callId); + break; + + case CallEventActionCallDecline(:final callKitParams): + // User declined the call via CallKit UI + final callId = callKitParams.id; + if (kDebugMode) { + log('❌ [CallKit] Call DECLINED via native UI - calling callback', name: 'CallKitService'); + log(' Call ID: $callId', name: 'CallKitService'); + } + onCallDeclined?.call(callId); + break; + + case CallEventActionCallEnded(:final callKitParams): + // User ended the call via CallKit UI + final callId = callKitParams.id; + if (kDebugMode) { + log('πŸ”΄ [CallKit] Call ENDED via native UI - calling callback', name: 'CallKitService'); + log(' Call ID: $callId', name: 'CallKitService'); + } + onCallEnded?.call(callId); + break; + + case CallEventActionCallTimeout(:final id): + // Call timed out (no answer) + if (kDebugMode) { + log('⏱️ [CallKit] Call TIMEOUT - calling callback', name: 'CallKitService'); + log(' Call ID: $id', name: 'CallKitService'); + } + onCallTimeout?.call(id); + break; + + case CallEventActionCallToggleMute(:final id, :final isMuted): + // User toggled mute via CallKit UI + if (kDebugMode) { + log('πŸ”‡ [CallKit] Mute toggled: $isMuted for call $id', name: 'CallKitService'); + } + break; + + case CallEventActionCallToggleHold(:final id, :final isOnHold): + // User toggled hold via CallKit UI + if (kDebugMode) { + log('⏸️ [CallKit] Hold toggled: $isOnHold for call $id', name: 'CallKitService'); + } + break; + + case CallEventActionCallIncoming(:final callKitParams): + // Incoming call notification shown + if (kDebugMode) { + log('πŸ“² [CallKit] Incoming call notification for: ${callKitParams.id}', name: 'CallKitService'); + } + break; + + case CallEventActionCallStart(:final callKitParams): + // Call started + if (kDebugMode) { + log('πŸ“ž [CallKit] Call started: ${callKitParams.id}', name: 'CallKitService'); + } + break; + + case CallEventActionCallCallback(:final id): + // Callback event + if (kDebugMode) { + log('πŸ“ž [CallKit] Callback event for call: $id', name: 'CallKitService'); + } + break; + + case CallEventActionCallConnected(:final id): + // Call connected + if (kDebugMode) { + log('βœ… [CallKit] Call connected: $id', name: 'CallKitService'); + } + break; + + case CallEventActionDidUpdateDevicePushTokenVoip(): + // Push token updated + if (kDebugMode) { + log('πŸ”” [CallKit] Device push token updated', name: 'CallKitService'); + } + break; + + case CallEventActionCallToggleDmtf(:final id, :final digits, :final type): + // DTMF toggled + if (kDebugMode) { + log('πŸ”’ [CallKit] DTMF toggled: $digits for call $id', name: 'CallKitService'); + } + break; + + case CallEventActionCallToggleGroup(:final id, :final callUUIDToGroupWith): + // Call group toggled + if (kDebugMode) { + log('πŸ‘₯ [CallKit] Group toggled for call $id with $callUUIDToGroupWith', name: 'CallKitService'); + } + break; + + case CallEventActionCallToggleAudioSession(:final isActive): + // Audio session toggled + if (kDebugMode) { + log('πŸ”Š [CallKit] Audio session toggled: $isActive', name: 'CallKitService'); + } + break; + + case CallEventActionCallCustom(:final body): + // Custom event + if (kDebugMode) { + log('πŸ”§ [CallKit] Custom event: $body', name: 'CallKitService'); + } + break; + + default: + if (kDebugMode) { + log('ℹ️ [CallKit] Unhandled event type: ${event.eventName}', name: 'CallKitService'); + } + } + } + + /// Show incoming call UI (CallKit on iOS, ConnectionService on Android) + Future showIncomingCall({ + required String callId, + required String callerName, + required String callerNumber, + String? callerAvatar, + required bool isVideo, + Map? extra, + }) async { + try { + if (kDebugMode) { + log('πŸ“ž [CallKit] Showing incoming call UI', name: 'CallKitService'); + log(' Caller: $callerName ($callerNumber)', name: 'CallKitService'); + log(' Video: $isVideo', name: 'CallKitService'); + log(' Call ID: $callId', name: 'CallKitService'); + } + + _currentCallId = callId; + + // Configure call parameters + final params = CallKitParams( + id: callId, + nameCaller: callerName, + appName: 'Atoms SA', + avatar: callerAvatar, + handle: callerNumber, + type: isVideo ? 1 : 0, // 0 = audio, 1 = video + duration: 30000, // 30 seconds timeout + extra: extra ?? {}, + headers: {'platform': 'flutter'}, + android: AndroidParams( + isCustomNotification: true, + isShowLogo: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0955fa', + backgroundUrl: callerAvatar ?? '', + actionColor: '#4CAF50', + textColor: '#ffffff', + incomingCallNotificationChannelName: 'Incoming Calls', + missedCallNotificationChannelName: 'Missed Calls', + ), + ios: const IOSParams( + iconName: 'CallKitLogo', + handleType: 'generic', + supportsVideo: true, + maximumCallGroups: 2, + maximumCallsPerCallGroup: 1, + audioSessionMode: 'videoChat', + audioSessionActive: true, + audioSessionPreferredSampleRate: 44100.0, + audioSessionPreferredIOBufferDuration: 0.005, + supportsDTMF: true, + supportsHolding: true, + supportsGrouping: false, + supportsUngrouping: false, + ringtonePath: 'system_ringtone_default', + ), + ); + + // Show the incoming call UI + await FlutterCallkitIncoming.showCallkitIncoming(params); + + if (kDebugMode) { + log('βœ… [CallKit] Incoming call UI displayed', name: 'CallKitService'); + } + } catch (e, stackTrace) { + log('❌ [CallKit] Error showing incoming call: $e', + name: 'CallKitService', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Start an outgoing call (show native UI) + Future startOutgoingCall({ + required String callId, + required String callerName, + required String callerNumber, + String? callerAvatar, + required bool isVideo, + }) async { + try { + if (kDebugMode) { + log('πŸ“ž [CallKit] Starting outgoing call', name: 'CallKitService'); + } + + _currentCallId = callId; + + final params = CallKitParams( + id: callId, + nameCaller: callerName, + appName: 'Atoms SA', + avatar: callerAvatar, + handle: callerNumber, + type: isVideo ? 1 : 0, + extra: {'outgoing': true}, + ios: const IOSParams( + handleType: 'generic', + supportsVideo: true, + audioSessionMode: 'videoChat', + audioSessionActive: true, + ), + ); + + await FlutterCallkitIncoming.startCall(params); + + if (kDebugMode) { + log('βœ… [CallKit] Outgoing call started', name: 'CallKitService'); + } + } catch (e) { + log('❌ [CallKit] Error starting outgoing call: $e', name: 'CallKitService'); + } + } + + /// Mark call as connected (when peer accepts) + Future setCallConnected(String callId) async { + try { + if (kDebugMode) { + log('βœ… [CallKit] Marking call as connected: $callId', name: 'CallKitService'); + } + + // Update call to connected state - this will update the native UI + await FlutterCallkitIncoming.setCallConnected(callId); + + if (kDebugMode) { + log('βœ… [CallKit] Call marked as connected', name: 'CallKitService'); + } + } catch (e) { + log('❌ [CallKit] Error setting call connected: $e', name: 'CallKitService'); + } + } + + /// End the current call + Future endCall(String callId) async { + try { + if (kDebugMode) { + log('πŸ”΄ [CallKit] Ending call: $callId', name: 'CallKitService'); + } + + await FlutterCallkitIncoming.endCall(callId); + + if (_currentCallId == callId) { + _currentCallId = null; + } + + if (kDebugMode) { + log('βœ… [CallKit] Call ended', name: 'CallKitService'); + } + } catch (e) { + log('❌ [CallKit] Error ending call: $e', name: 'CallKitService'); + } + } + + /// End all active calls + Future endAllCalls() async { + try { + if (kDebugMode) { + log('πŸ”΄ [CallKit] Ending all calls', name: 'CallKitService'); + } + + await FlutterCallkitIncoming.endAllCalls(); + _currentCallId = null; + + if (kDebugMode) { + log('βœ… [CallKit] All calls ended', name: 'CallKitService'); + } + } catch (e) { + log('❌ [CallKit] Error ending all calls: $e', name: 'CallKitService'); + } + } + + /// Get all active calls + Future> getActiveCalls() async { + try { + final calls = await FlutterCallkitIncoming.activeCalls(); + if (kDebugMode) { + log('πŸ“‹ [CallKit] Active calls: ${calls.length}', name: 'CallKitService'); + } + return calls; + } catch (e) { + log('❌ [CallKit] Error getting active calls: $e', name: 'CallKitService'); + return []; + } + } + + /// Dispose and cleanup + Future dispose() async { + try { + if (kDebugMode) { + log('🧹 [CallKit] Disposing service', name: 'CallKitService'); + } + + // Cancel event subscription + await _eventSubscription?.cancel(); + _eventSubscription = null; + + // End all active calls + await endAllCalls(); + + // Clear callbacks + onCallAccepted = null; + onCallDeclined = null; + onCallEnded = null; + onCallTimeout = null; + + if (kDebugMode) { + log('βœ… [CallKit] Service disposed', name: 'CallKitService'); + } + } catch (e) { + log('❌ [CallKit] Error disposing service: $e', name: 'CallKitService'); + } + } +} diff --git a/lib/modules/cx_module/chat/call/services/webrtc_service.dart b/lib/modules/cx_module/chat/call/services/webrtc_service.dart new file mode 100644 index 00000000..229a16c9 --- /dev/null +++ b/lib/modules/cx_module/chat/call/services/webrtc_service.dart @@ -0,0 +1,617 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +/// WebRTC service for handling peer-to-peer audio/video connections +class WebRTCService { + // Peer connection + RTCPeerConnection? _peerConnection; + + // Media streams + MediaStream? _localStream; + MediaStream? _remoteStream; + + // Video renderers (for future video support) + RTCVideoRenderer? localRenderer; + RTCVideoRenderer? remoteRenderer; + + // Callbacks + Function(MediaStream stream)? onLocalStream; + Function(MediaStream stream)? onRemoteStream; + Function(RTCIceCandidate candidate)? onIceCandidate; + Function(RTCIceConnectionState state)? onIceConnectionStateChange; + Function()? onCallEnded; + + // ICE candidate queue (for candidates received before remote description) + final List _iceCandidateQueue = []; + bool _remoteDescriptionSet = false; + + // Track if current call is video call + bool _isVideoCall = false; + + // Track if we're using test mode (Google STUN only) + static bool _useTestMode = false; + + /// Enable test mode (use only Google STUN servers for debugging) + static void enableTestMode() { + _useTestMode = true; + if (kDebugMode) { + log('βš™οΈ [WebRTC] Test mode enabled', name: 'WebRTCService'); + } + } + + /// Disable test mode (use backend TURN servers) + static void disableTestMode() { + _useTestMode = false; + if (kDebugMode) { + log('βš™οΈ [WebRTC] Test mode disabled', name: 'WebRTCService'); + } + } + + // Configuration + static const Map _mediaConstraints = { + 'audio': true, + 'video': false, + }; + + // Video constraints for video calls + static const Map _videoConstraints = { + 'audio': true, + 'video': { + 'facingMode': 'user', + 'width': {'ideal': 1280}, + 'height': {'ideal': 720}, + }, + }; + + // ICE servers configuration for TEST MODE (Google STUN only) + static const Map _testConfiguration = { + 'iceServers': [ + { + 'urls': [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + 'stun:stun2.l.google.com:19302', + ] + }, + ], + 'sdpSemantics': 'unified-plan', + 'iceTransportPolicy': 'all', + 'iceCandidatePoolSize': 10, + }; + + // ICE servers configuration (from backend) + final Map _configuration = { + 'iceServers': [ + // Primary STUN servers (Google's public STUN - highly reliable) + { + 'urls': [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + ] + }, + + // Backend TURN server (PRIMARY) + { + 'urls': [ + 'turn:15.185.116.59:3479', + 'turn:15.185.116.59:3479?transport=tcp', + ], + 'username': 'admin', + 'credential': 'admin' + }, + + // FALLBACK: Public TURN servers + { + 'urls': 'turn:a.relay.metered.ca:80', + 'username': 'e14d93f87f9ff517f1bee797', + 'credential': 'PGfCGmR6CR2aM9OY', + }, + { + 'urls': 'turn:a.relay.metered.ca:443', + 'username': 'e14d93f87f9ff517f1bee797', + 'credential': 'PGfCGmR6CR2aM9OY', + }, + ], + 'sdpSemantics': 'unified-plan', + 'iceTransportPolicy': 'all', + 'iceCandidatePoolSize': 10, + 'bundlePolicy': 'max-bundle', + 'rtcpMuxPolicy': 'require', + }; + + /// Initialize WebRTC for audio call + Future initializeForAudioCall() async { + try { + if (kDebugMode) { + log('πŸ”§ [WebRTC] Initializing audio call', name: 'WebRTCService'); + } + + _isVideoCall = false; + + // Get local audio stream + try { + _localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints); + } catch (e) { + log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e); + throw Exception('Microphone access denied or unavailable'); + } + + // Create peer connection + _peerConnection = await createPeerConnection(_configuration); + + if (_peerConnection == null) { + throw Exception('Failed to create peer connection'); + } + + // Add local stream tracks to peer connection + _localStream!.getTracks().forEach((track) { + _peerConnection!.addTrack(track, _localStream!); + }); + + // Setup peer connection event handlers + _setupPeerConnectionListeners(); + + if (kDebugMode) { + log('βœ… [WebRTC] Audio call initialized', name: 'WebRTCService'); + } + } catch (e, stackTrace) { + log('❌ [WebRTC] Initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); + // Clean up any partial initialization + await dispose(); + rethrow; + } + } + + /// Initialize WebRTC for video call + Future initializeForVideoCall() async { + try { + if (kDebugMode) { + log('πŸ”§ [WebRTC] Initializing video call', name: 'WebRTCService'); + } + + _isVideoCall = true; + + // Initialize video renderers + localRenderer = RTCVideoRenderer(); + remoteRenderer = RTCVideoRenderer(); + + try { + await localRenderer!.initialize(); + await remoteRenderer!.initialize(); + } catch (e) { + log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e); + throw Exception('Failed to initialize video renderers'); + } + + // Create peer connection FIRST (before getting media) + final config = _useTestMode ? _testConfiguration : _configuration; + _peerConnection = await createPeerConnection(config); + + if (_peerConnection == null) { + throw Exception('Failed to create peer connection'); + } + + // Setup peer connection listeners immediately + _setupPeerConnectionListeners(); + + // Get local audio + video stream + try { + _localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints); + } catch (e) { + log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e); + throw Exception('Camera or microphone access denied or unavailable'); + } + + // Set local stream to renderer + if (localRenderer != null) { + localRenderer!.srcObject = _localStream; + } + + // Add local stream tracks to peer connection + _localStream!.getTracks().forEach((track) { + _peerConnection!.addTrack(track, _localStream!); + }); + + if (kDebugMode) { + log('βœ… [WebRTC] Video call initialized', name: 'WebRTCService'); + } + } catch (e, stackTrace) { + log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); + // Clean up any partial initialization + await dispose(); + rethrow; + } + } + + /// Setup peer connection event listeners + void _setupPeerConnectionListeners() { + // Handle ICE candidates + _peerConnection!.onIceCandidate = (RTCIceCandidate candidate) { + if (onIceCandidate != null) { + onIceCandidate!(candidate); + } + }; + + // Handle ICE gathering state changes + _peerConnection!.onIceGatheringState = (RTCIceGatheringState state) { + if (kDebugMode && state == RTCIceGatheringState.RTCIceGatheringStateComplete) { + log('βœ… [WebRTC] ICE gathering completed', name: 'WebRTCService'); + } + }; + + // Handle ICE connection state changes + _peerConnection!.onIceConnectionState = (RTCIceConnectionState state) { + if (kDebugMode) { + log('πŸ”— [WebRTC] ICE state: ${state.toString()}', name: 'WebRTCService'); + } + + // Log critical failures + if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) { + log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService'); + } + + if (onIceConnectionStateChange != null) { + onIceConnectionStateChange!(state); + } + }; + + // Handle remote stream + _peerConnection!.onTrack = (RTCTrackEvent event) { + if (event.streams.isNotEmpty) { + final stream = event.streams[0]; + + // If this is the first remote stream or a different stream, update it + if (_remoteStream == null || _remoteStream!.id != stream.id) { + _remoteStream = stream; + + // For video calls, assign stream to renderer + if (remoteRenderer != null && _isVideoCall) { + try { + remoteRenderer!.srcObject = _remoteStream; + + // Retry assignment after delays to ensure it sticks + Future.delayed(const Duration(milliseconds: 100), () { + if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { + remoteRenderer!.srcObject = _remoteStream; + } + }); + + Future.delayed(const Duration(milliseconds: 300), () { + if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { + remoteRenderer!.srcObject = _remoteStream; + } + }); + } catch (e) { + log('⚠️ [WebRTC] Failed to assign remote stream: $e', name: 'WebRTCService'); + } + } + + // Notify callback + if (onRemoteStream != null) { + onRemoteStream!(_remoteStream!); + } + + if (kDebugMode) { + log('πŸ“‘ [WebRTC] Remote stream received', name: 'WebRTCService'); + } + } else { + // Additional track on existing stream + if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) { + try { + remoteRenderer!.srcObject = _remoteStream; + } catch (e) { + log('⚠️ [WebRTC] Failed to assign stream (additional track): $e', name: 'WebRTCService'); + } + } + + if (onRemoteStream != null) { + onRemoteStream!(_remoteStream!); + } + } + } + }; + + // Handle connection state changes + _peerConnection!.onConnectionState = (RTCPeerConnectionState state) { + if (kDebugMode || state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) { + log('πŸ”Œ [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService'); + } + }; + } + + /// Create SDP offer (caller side) + Future createOffer() async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + final offer = await _peerConnection!.createOffer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + 'iceRestart': false, + }); + + await _peerConnection!.setLocalDescription(offer); + + if (kDebugMode) { + log('βœ… [WebRTC] SDP offer created', name: 'WebRTCService'); + } + + return offer; + } catch (e, stackTrace) { + log('❌ [WebRTC] Error creating offer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Create SDP answer (callee side) + Future createAnswer(String offerSdp) async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + // Set remote description (offer from caller) + final offer = RTCSessionDescription(offerSdp, 'offer'); + await _peerConnection!.setRemoteDescription(offer); + _remoteDescriptionSet = true; + + // Flush queued ICE candidates + await _flushIceCandidateQueue(); + + // Create answer + final answer = await _peerConnection!.createAnswer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + }); + + await _peerConnection!.setLocalDescription(answer); + + if (kDebugMode) { + log('βœ… [WebRTC] SDP answer created', name: 'WebRTCService'); + } + + return answer; + } catch (e, stackTrace) { + log('❌ [WebRTC] Error creating answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Set remote answer (caller side) + Future setRemoteAnswer(String answerSdp) async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + final answer = RTCSessionDescription(answerSdp, 'answer'); + await _peerConnection!.setRemoteDescription(answer); + _remoteDescriptionSet = true; + + // Flush queued ICE candidates + await _flushIceCandidateQueue(); + + if (kDebugMode) { + log('βœ… [WebRTC] Remote answer set', name: 'WebRTCService'); + } + } catch (e, stackTrace) { + log('❌ [WebRTC] Error setting remote answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Send SDP answer (convenience method for callee) + Future sendSDPAnswer(String? offerSdp) async { + if (offerSdp == null) { + throw Exception('Offer SDP is null'); + } + return await createAnswer(offerSdp); + } + + /// Add remote ICE candidate + Future addIceCandidate(RTCIceCandidate candidate) async { + try { + // If peer connection not created yet, queue the candidate + if (_peerConnection == null) { + _iceCandidateQueue.add(candidate); + return; + } + + // If remote description not set yet, queue the candidate + if (!_remoteDescriptionSet) { + _iceCandidateQueue.add(candidate); + return; + } + + await _peerConnection!.addCandidate(candidate); + } catch (e) { + log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService'); + // Don't rethrow - ICE candidate errors shouldn't break the call + } + } + + /// Flush queued ICE candidates + Future _flushIceCandidateQueue() async { + if (_iceCandidateQueue.isEmpty) { + return; + } + + if (kDebugMode) { + log('πŸ”„ [WebRTC] Flushing ${_iceCandidateQueue.length} ICE candidates', name: 'WebRTCService'); + } + + for (final candidate in _iceCandidateQueue) { + try { + await _peerConnection!.addCandidate(candidate); + } catch (e) { + log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService'); + } + } + + _iceCandidateQueue.clear(); + } + + /// Toggle microphone mute + void setMicrophoneMuted(bool muted) { + if (_localStream != null) { + final audioTracks = _localStream!.getAudioTracks(); + for (final track in audioTracks) { + track.enabled = !muted; + } + } + } + + /// Toggle camera on/off for video calls + void setCameraEnabled(bool enabled) { + if (_localStream != null) { + final videoTracks = _localStream!.getVideoTracks(); + for (final track in videoTracks) { + track.enabled = enabled; + } + } + } + + /// Switch between front and rear camera + Future switchCamera() async { + if (_localStream != null) { + final videoTracks = _localStream!.getVideoTracks(); + if (videoTracks.isNotEmpty) { + try { + await Helper.switchCamera(videoTracks.first); + if (kDebugMode) { + log('βœ… [WebRTC] Camera switched', name: 'WebRTCService'); + } + } catch (e) { + log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService'); + } + } + } + } + + /// Enable/disable speakerphone + Future setSpeakerphoneEnabled(bool enabled) async { + try { + await Helper.setSpeakerphoneOn(enabled); + } catch (e) { + log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService'); + } + } + + /// Get local stream + MediaStream? get localStream => _localStream; + + /// Get remote stream + MediaStream? get remoteStream => _remoteStream; + + /// Check if remote stream has video tracks + bool get hasRemoteVideo { + if (_remoteStream == null) return false; + return _remoteStream!.getVideoTracks().isNotEmpty; + } + + /// Force refresh remote renderer + void refreshRemoteRenderer() { + if (_remoteStream != null && remoteRenderer != null && _isVideoCall) { + if (remoteRenderer!.srcObject == null) { + remoteRenderer!.srcObject = _remoteStream; + if (kDebugMode) { + log('βœ… [WebRTC] Remote renderer refreshed', name: 'WebRTCService'); + } + } + } + } + + /// Restart ICE negotiation (used when connection fails) + Future restartIce() async { + try { + if (_peerConnection == null) { + return null; + } + + final offer = await _peerConnection!.createOffer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + 'iceRestart': true, + }); + + await _peerConnection!.setLocalDescription(offer); + + if (kDebugMode) { + log('βœ… [WebRTC] ICE restart initiated', name: 'WebRTCService'); + } + + return offer; + } catch (e) { + log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService'); + return null; + } + } + + /// Get peer connection state + RTCPeerConnectionState? getPeerConnectionState() { + return _peerConnection?.connectionState; + } + + /// Get ICE connection state + RTCIceConnectionState? getIceConnectionState() { + return _peerConnection?.iceConnectionState; + } + + /// Check if peer connection is initialized + bool get isPeerConnectionInitialized => _peerConnection != null; + + /// Dispose and cleanup + Future dispose() async { + try { + // Stop local stream tracks + if (_localStream != null) { + _localStream!.getTracks().forEach((track) { + track.stop(); + }); + await _localStream!.dispose(); + _localStream = null; + } + + // Stop remote stream tracks + if (_remoteStream != null) { + _remoteStream!.getTracks().forEach((track) { + track.stop(); + }); + await _remoteStream!.dispose(); + _remoteStream = null; + } + + // Dispose renderers + if (localRenderer != null) { + await localRenderer!.dispose(); + localRenderer = null; + } + + if (remoteRenderer != null) { + await remoteRenderer!.dispose(); + remoteRenderer = null; + } + + // Close peer connection + if (_peerConnection != null) { + await _peerConnection!.close(); + await _peerConnection!.dispose(); + _peerConnection = null; + } + + // Clear ICE candidate queue + _iceCandidateQueue.clear(); + _remoteDescriptionSet = false; + + if (kDebugMode) { + log('βœ… [WebRTC] Service disposed', name: 'WebRTCService'); + } + } catch (e) { + log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService'); + } + } +} diff --git a/lib/modules/cx_module/chat/call/video_call_page.dart b/lib/modules/cx_module/chat/call/video_call_page.dart index e1a02b55..3e256a84 100644 --- a/lib/modules/cx_module/chat/call/video_call_page.dart +++ b/lib/modules/cx_module/chat/call/video_call_page.dart @@ -1,7 +1,9 @@ +import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; - -// import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:test_sa/extensions/context_extension.dart'; @@ -22,74 +24,328 @@ class VideoCallPage extends StatefulWidget { } class _VideoCallPageState extends State { + Timer? _streamCheckTimer; + bool _isLocalVideoFullscreen = false; + @override void initState() { super.initState(); + + if (kDebugMode) { + log('🎬 [VIDEO CALL PAGE] Page initialized', name: 'VideoCallPage'); + } + WidgetsBinding.instance.addPostFrameCallback((_) { - // Initialize video call + final chatProvider = context.read(); + + // Add listener to detect when call ends + chatProvider.addListener(_checkCallStatus); + + // Start periodic check to ensure remote stream gets assigned + _startStreamCheckTimer(); + }); + } + + void _startStreamCheckTimer() { + // Check every 500ms if remote stream needs to be assigned + _streamCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) { + final chatProvider = context.read(); + final webrtc = chatProvider.webrtcService; + + if (webrtc == null || !mounted) { + timer.cancel(); + return; + } + + // If we have remote stream but renderer doesn't have it, assign it + if (webrtc.remoteStream != null && + webrtc.remoteRenderer != null && + webrtc.remoteRenderer!.srcObject == null) { + try { + webrtc.remoteRenderer!.srcObject = webrtc.remoteStream; + + // Force UI rebuild + if (mounted) { + setState(() {}); + } + } catch (e) { + if (kDebugMode) { + log('⚠️ [VIDEO CALL PAGE] Stream assignment failed: $e', name: 'VideoCallPage'); + } + } + } + + // If call is connected and remote stream is assigned, slow down checks + if (chatProvider.callStatus == CallStatus.connected && + webrtc.remoteRenderer?.srcObject != null) { + timer.cancel(); + + // Switch to slower monitoring (every 5 seconds) + Timer.periodic(const Duration(seconds: 5), (slowTimer) { + if (!mounted) { + slowTimer.cancel(); + } + }); + } }); } + void _checkCallStatus() { + final chatProvider = context.read(); + + // If call ended, navigate back + if (chatProvider.callStatus == CallStatus.idle && mounted) { + chatProvider.removeListener(_checkCallStatus); + Navigator.of(context).pop(); + } + } + + @override + void dispose() { + // Cancel stream check timer + _streamCheckTimer?.cancel(); + _streamCheckTimer = null; + + // Clean up listener + try { + context.read().removeListener(_checkCallStatus); + } catch (e) { + // Provider might already be disposed + if (kDebugMode) { + log('⚠️ [VIDEO CALL PAGE] Error removing listener: $e', name: 'VideoCallPage'); + } + } + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.backgroundTabBarDark, - body: Selector( - selector: (_, provider) => provider.currentCall, - builder: (context, session, _) { - if (session == null) { - return const Center( - child: Text( - 'No active call', - style: TextStyle(color: Colors.white), - ), - ); - } + body: SafeArea( + top: false, + child: Selector( + selector: (_, provider) => provider.currentCall, + builder: (context, session, _) { + if (session == null) { + return const Center( + child: Text( + 'No active call', + style: TextStyle(color: Colors.white), + ), + ); + } - return Stack( - children: [ - _buildRemoteVideo(context), - _buildLocalVideo(context), - _buildTopBar(context, session), - _buildBottomControls(context, session), - ], - ); - }, + return Stack( + children: [ + // Background video (fullscreen) + _isLocalVideoFullscreen ? _buildFullscreenLocalVideo(context) : _buildRemoteVideo(context), + + // Small preview video (top-right corner) + _isLocalVideoFullscreen ? _buildSmallRemoteVideo(context) : _buildLocalVideo(context), + + _buildTopBar(context, session), + _buildBottomControls(context, session), + ], + ); + }, + ), ), ); } - Widget _buildRemoteVideo(BuildContext context) { + // Fullscreen local video (when tapped) + Widget _buildFullscreenLocalVideo(BuildContext context) { final chatProvider = context.read(); return Positioned.fill( - child: Selector( - selector: (_, provider) => provider.isPeerCameraOn, - builder: (context, isPeerCameraOn, _) { - if (!isPeerCameraOn) { - // Show avatar when peer camera is off - return _buildRemoteAvatarPlaceholder(context); - } + child: GestureDetector( + onTap: () { + setState(() { + _isLocalVideoFullscreen = false; // Switch back to remote fullscreen + }); + }, + child: Consumer( + builder: (context, chatProvider, _) { + if (!chatProvider.isCameraOn) { + // Show avatar when camera is off + return Container( + color: Colors.grey[800], + child: const Center( + child: Icon( + Icons.videocam_off, + color: Colors.white, + size: 64, + ), + ), + ); + } - // TODO: Replace with actual RTCVideoView when WebRTC is wired - return Container( - color: Colors.black, - child: const Center( - child: Text( - 'Remote Video', - style: TextStyle(color: Colors.white54), + if (chatProvider.webrtcService?.localRenderer != null) { + return RTCVideoView( + chatProvider.webrtcService!.localRenderer!, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + mirror: true, + ); + } + + return Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(color: Colors.white54), ), - ), - ); + ); + }, + ), + ), + ); + } - /* Will be replaced with: - return RTCVideoView( - chatProvider.webrtcService.remoteRenderer, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, - mirror: false, - ); - */ + // Small remote video (when local is fullscreen) + Widget _buildSmallRemoteVideo(BuildContext context) { + return Positioned( + top: MediaQuery.of(context).padding.top + 80, + right: 16, + child: GestureDetector( + onTap: () { + setState(() { + _isLocalVideoFullscreen = false; // Switch back to remote fullscreen + }); + }, + child: Consumer( + builder: (context, chatProvider, _) { + final remoteStream = chatProvider.webrtcService?.remoteStream; + final remoteRenderer = chatProvider.webrtcService?.remoteRenderer; + + if (!chatProvider.isPeerCameraOn) { + // Show avatar placeholder + return Container( + width: 120, + height: 160, + decoration: BoxDecoration( + color: AppColor.backgroundTabBarDark, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.videocam_off, color: Colors.white, size: 32), + 8.height, + Text( + chatProvider.currentCall?.peerName.split(' ').first ?? '', + style: AppTextStyles.bodyText2.copyWith( + color: Colors.white, + fontSize: 12, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + if (remoteRenderer != null && remoteStream != null && remoteRenderer.srcObject != null) { + return Container( + width: 120, + height: 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: RTCVideoView( + remoteRenderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + mirror: false, + ), + ), + ); + } + + return Container( + width: 120, + height: 160, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), + ), + child: const Center( + child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), + ), + ); + }, + ), + ), + ); + } + + Widget _buildRemoteVideo(BuildContext context) { + return Positioned.fill( + child: GestureDetector( + onTap: () { + setState(() { + _isLocalVideoFullscreen = true; // Switch to local fullscreen + }); }, + child: Consumer( + builder: (context, chatProvider, _) { + final remoteRenderer = chatProvider.webrtcService?.remoteRenderer; + final remoteStream = chatProvider.webrtcService?.remoteStream; + + if (remoteRenderer != null) { + if (remoteRenderer.srcObject == null && remoteStream != null) { + // Renderer exists but srcObject is null, assign it + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderer = chatProvider.webrtcService?.remoteRenderer; + final stream = chatProvider.webrtcService?.remoteStream; + if (renderer != null && stream != null && renderer.srcObject == null) { + renderer.srcObject = stream; + // Force UI rebuild + if (mounted) { + setState(() {}); + } + } + }); + } + + // If renderer has stream OR we just assigned it, show the video view + final hasRendererStream = remoteRenderer.srcObject != null; + + if (hasRendererStream) { + return RTCVideoView( + remoteRenderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + mirror: false, + ); + } + } + + // Fallback while waiting for remote stream + return Container( + color: Colors.black, + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white54), + SizedBox(height: 16), + Text( + 'Waiting for video...', + style: TextStyle(color: Colors.white54), + ), + ], + ), + ), + ); + }, + ), ), ); } @@ -101,143 +357,294 @@ class _VideoCallPageState extends State { if (session == null) return const SizedBox.shrink(); return Container( - color: AppColor.backgroundTabBarDark, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColor.whiteF8d, - border: Border.all(color: AppColor.white10.withAlpha(51), width: 1), - ), - child: ClipOval( - child: session.peerAvatar != null - ? CachedNetworkImage( - imageUrl: session.peerAvatar!, - fit: BoxFit.cover, - placeholder: (context, url) => Center( - child: CircularProgressIndicator( - color: Colors.white.withAlpha(128), + color: AppColor.backgroundTabBarDark, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.whiteF8d, + border: Border.all(color: AppColor.white10.withAlpha(51), width: 1), + ), + child: ClipOval( + child: session.peerAvatar != null + ? CachedNetworkImage( + imageUrl: session.peerAvatar!, + fit: BoxFit.cover, + placeholder: (context, url) => Center( + child: CircularProgressIndicator( + color: Colors.white.withAlpha(128), + ), + ), + errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ) + : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ), + ), + // Show mute indicator badge when peer is muted + Selector( + selector: (_, provider) => provider.isPeerMuted, + builder: (context, isPeerMuted, _) { + if (!isPeerMuted) return const SizedBox.shrink(); + + return Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColor.red30, + shape: BoxShape.circle, + border: Border.all(color: AppColor.white10, width: 2), + ), + child: 'mic_disable'.toSvgAsset( + width: 16, + height: 16, + color: AppColor.white10, ), ), - errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), - ) - : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), + ); + }, + ), + ], ), - ), - 24.height, - Text( - session.peerName, - style: AppTextStyles.heading2.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, + 24.height, + Text( + session.peerName, + style: AppTextStyles.heading2.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - 8.height, - Text( - 'Camera is off', - style: AppTextStyles.heading5.copyWith( - color: AppColor.neutral100, - fontWeight: FontWeight.w400, + 8.height, + Text( + 'Camera is off', + style: AppTextStyles.heading5.copyWith( + color: AppColor.neutral100, + fontWeight: FontWeight.w400, + ), ), - ), - ], - ), - ), - ); + // Show mute status when peer is muted + Selector( + selector: (_, provider) => provider.isPeerMuted, + builder: (context, isPeerMuted, _) { + if (!isPeerMuted) return const SizedBox.shrink(); + + return Column( + children: [ + 8.height, + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColor.red30.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + 'mic_disable'.toSvgAsset( + width: 14, + height: 14, + color: AppColor.white10, + ), + 6.width, + Text( + 'Microphone is off', + style: AppTextStyles.bodyText2.copyWith( + color: AppColor.white10, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ], + ), + )); }, ); } Widget _buildLocalVideo(BuildContext context) { - return Selector( - selector: (_, provider) => provider.isCameraOn, - builder: (context, isCameraOn, _) { - if (!isCameraOn) { - // Show avatar when local camera is off - return DraggableLocalPreview( - child: Container( + final chatProvider = context.read(); + + return Positioned( + top: MediaQuery.of(context).padding.top + 80, + right: 16, + child: GestureDetector( + onTap: () { + setState(() { + _isLocalVideoFullscreen = true; // Make local video fullscreen + }); + }, + child: Selector( + selector: (_, provider) => provider.isCameraOn, + builder: (context, isCameraOn, _) { + if (!isCameraOn) { + return Container( + width: 120, + height: 160, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.videocam_off, + color: Colors.white, + size: 32, + ), + ); + } + + if (chatProvider.webrtcService?.localRenderer != null) { + return Container( + width: 120, + height: 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: RTCVideoView( + chatProvider.webrtcService!.localRenderer!, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + mirror: true, + ), + ), + ); + } + + return Container( width: 120, height: 160, decoration: BoxDecoration( - color: Colors.grey[800], + color: Colors.black, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), - child: const Icon( - Icons.videocam_off, - color: Colors.white, - size: 32, - ), - ), - ); - } - - // TODO: Replace with actual RTCVideoView when WebRTC is wired - return DraggableLocalPreview( - child: Container( - width: 120, - height: 160, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white, width: 2), - ), - child: const Center( - child: Text( - 'You', - style: TextStyle(color: Colors.white54), + child: const Center( + child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), ), - ), - ), - ); - - /* Will be replaced with: - final chatProvider = context.read(); - return DraggableLocalPreview( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: RTCVideoView( - chatProvider.webrtcService.localRenderer, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, - mirror: true, - ), - ), - ); - */ - }, + ); + }, + ), + ), ); } Widget _buildTopBar(BuildContext context, CallSession session) { - return Align( - alignment: AlignmentGeometry.topLeft, - child: Padding( + return Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.7), + Colors.transparent, + ], + ), + ), padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, + top: MediaQuery.of(context).padding.top + 8, + left: 16, + right: 16, + bottom: 24, ), - child: IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: AppColor.white10, - ), - onPressed: () => Navigator.pop(context), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + session.peerName, + style: AppTextStyles.bodyText2.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + 4.height, + _buildCallStatusAndDuration(), + ], + ), + ), + const SizedBox(width: 48), + ], ), ), ); } + Widget _buildCallStatusAndDuration() { + return Selector( + selector: (_, provider) => provider.callDuration, + builder: (context, duration, _) { + if (duration.inSeconds == 0) { + return Selector( + selector: (_, provider) => provider.callStatus, + builder: (context, status, _) { + String statusText; + switch (status) { + case CallStatus.outgoingRinging: + statusText = 'Calling...'; + break; + case CallStatus.connecting: + statusText = 'Connecting...'; + break; + default: + statusText = ''; + } + return Text( + statusText, + style: AppTextStyles.bodyText.copyWith( + color: Colors.white70, + ), + ); + }, + ); + } + + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final minutes = twoDigits(duration.inMinutes.remainder(60)); + final seconds = twoDigits(duration.inSeconds.remainder(60)); + + return Text( + '$minutes:$seconds', + style: AppTextStyles.bodyText.copyWith( + color: Colors.white, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ); + }, + ); + } + Widget _buildBottomControls(BuildContext context, CallSession session) { final chatProvider = context.read(); return Positioned( - bottom: 24, + bottom: 0, left: 0, right: 0, - //for now use these colors, later we can change to use the theme colors child: Container( decoration: BoxDecoration( gradient: const LinearGradient( @@ -257,13 +664,57 @@ class _VideoCallPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - session.peerName, - style: AppTextStyles.heading2.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, + // Name with mute indicator on the right + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + session.peerName, + style: AppTextStyles.heading2.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + // Mute indicator badge (top-right of name) + Selector( + selector: (_, provider) => provider.isPeerMuted, + builder: (context, isPeerMuted, _) { + if (!isPeerMuted) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColor.red30, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColor.white10.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + 'mic_disable'.toSvgAsset( + width: 14, + height: 14, + color: AppColor.white10, + ), + 4.width, + Text( + 'Muted', + style: AppTextStyles.bodyText2.copyWith( + color: AppColor.white10, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + }, + ), + ], ), 16.height, _buildCallStatusOrDuration(context), @@ -318,9 +769,7 @@ class _VideoCallPageState extends State { showBorder: false, onPressed: () { chatProvider.hangUp(); - if (mounted) { - Navigator.pop(context); - } + // Removed Navigator.pop() - automatic listener will handle navigation }, ), ], diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index 9fa6741f..0742d551 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -4,8 +4,6 @@ import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; import 'package:audio_waveforms/audio_waveforms.dart'; - -// import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -14,29 +12,6 @@ import 'package:intl/intl.dart'; import 'package:just_audio/just_audio.dart' as JustAudio; import 'package:just_audio/just_audio.dart'; -// import 'package:mohem_flutter_app/api/chat/chat_api_client.dart'; -// import 'package:mohem_flutter_app/api/my_team/my_team_api_client.dart'; -// import 'package:mohem_flutter_app/app_state/app_state.dart'; -// import 'package:mohem_flutter_app/classes/consts.dart'; -// import 'package:mohem_flutter_app/classes/encryption.dart'; -// import 'package:mohem_flutter_app/classes/utils.dart'; -// import 'package:mohem_flutter_app/config/routes.dart'; -// import 'package:mohem_flutter_app/main.dart'; -// import 'package:mohem_flutter_app/models/chat/chat_user_image_model.dart'; -// import 'package:mohem_flutter_app/models/chat/create_group_request.dart' as createGroup; -// import 'package:mohem_flutter_app/models/chat/get_group_chat_history.dart' as groupchathistory; -// import 'package:mohem_flutter_app/models/chat/get_search_user_chat_model.dart'; -// import 'package:mohem_flutter_app/models/chat/get_single_user_chat_list_model.dart'; -// import 'package:mohem_flutter_app/models/chat/get_user_groups_by_id.dart' as groups; -// import 'package:mohem_flutter_app/models/chat/get_user_groups_by_id.dart'; -// import 'package:mohem_flutter_app/models/chat/get_user_login_token_model.dart' as userLoginToken; -// import 'package:mohem_flutter_app/models/chat/make_user_favotire_unfavorite_chat_model.dart' as fav; -// import 'package:mohem_flutter_app/models/chat/target_users.dart'; -// import 'package:mohem_flutter_app/models/my_team/get_employee_subordinates_list.dart'; -// import 'package:mohem_flutter_app/ui/chat/chat_detailed_screen.dart'; -// import 'package:mohem_flutter_app/ui/landing/dashboard_screen.dart'; -// import 'package:mohem_flutter_app/widgets/image_picker.dart'; -// import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:signalr_netcore/hub_connection.dart'; @@ -45,6 +20,8 @@ import 'package:test_sa/controllers/api_routes/api_manager.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/main.dart'; +import 'package:test_sa/modules/cx_module/chat/call/audio_call_page.dart'; +import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart'; import 'package:test_sa/modules/cx_module/chat/model/chat_login_response_model.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter/material.dart' as Material; @@ -60,9 +37,11 @@ import 'model/unread_message_model.dart'; import 'model/user_chat_history_model.dart'; import 'model/call_session.dart'; import 'call/call_debug_helper.dart'; -import 'call/incoming_call_dialog.dart'; // NEW - Import incoming call dialog - -//Need to refactor this remove unused code. +import 'call/incoming_call_dialog.dart'; +import 'call/services/webrtc_service.dart'; +import 'call/services/callkit_service.dart'; // Add CallKit service +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'call/call_error_handler.dart'; HubConnection? chatHubConnection; @@ -108,10 +87,30 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // NEW: Call handlers registration status bool _callHandlersRegistered = false; + bool get areCallHandlersRegistered => _callHandlersRegistered; bool get isCallInProgress => callStatus != CallStatus.idle; + // NEW: WebRTC service instance + WebRTCService? _webrtcService; + + // Public getter for WebRTC service (for video renderers access) + WebRTCService? get webrtcService => _webrtcService; + + // NEW: CallKit service instance + final CallKitService _callKitService = CallKitService(); + bool _callKitInitialized = false; + + // NEW: Remote stream for audio playback + MediaStream? _remoteMediaStream; + + MediaStream? get remoteMediaStream => _remoteMediaStream; + + // NEW: Ringtone player for outgoing calls + final JustAudio.AudioPlayer _ringingPlayer = JustAudio.AudioPlayer(); + bool _isRingingPlaying = false; + /// OPTIMIZATION: Improved connection disposal to prevent memory leaks /// This properly handles errors and ensures connection is always cleaned up Future _disposeConnection() async { @@ -201,9 +200,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // Use case-insensitive matching and handle not found gracefully try { - sender = chatParticipantModel?.participants?.firstWhere( - (participant) => participant.employeeNumber?.toLowerCase() == myId.toLowerCase() - ); + sender = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == myId.toLowerCase()); log('βœ… sender i got is ${sender?.toJson()}'); } catch (e) { log('⚠️ Sender NOT FOUND for myId: $myId. Error: $e'); @@ -211,9 +208,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { } try { - recipient = chatParticipantModel?.participants?.firstWhere( - (participant) => participant.employeeNumber?.toLowerCase() == assigneeEmployeeNumber.toLowerCase() - ); + recipient = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == assigneeEmployeeNumber.toLowerCase()); log('βœ… recipient i got is ${recipient?.toJson()}'); } catch (e) { log('⚠️ Recipient NOT FOUND for assigneeEmployeeNumber: $assigneeEmployeeNumber. Error: $e'); @@ -404,6 +399,9 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { chatHubConnection!.on("OnSeenChatUserAsync", onSeenUserChatAsync); chatHubConnection!.on("OnAckSeenAsync", onAckSeenAsync); + // Register call history event handler + chatHubConnection!.on("OnCallHistoryUpdated", _onCallHistoryUpdated); + // Register call event handlers _registerCallHandlers(); @@ -876,28 +874,31 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // ...existing code... } - // ==================== PHASE 1: CALL INFRASTRUCTURE ==================== + // ==================== CALL INFRASTRUCTURE ==================== // All call-related methods are placed at the end to avoid modifying existing chat logic /// Start a new audio or video call Future startCall(Participants recipient, CallType callType) async { log('πŸ”΅ [CALL] startCall() invoked', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Parameters - recipient: ${recipient.employeeNumber}, callType: $callType', name: 'ChatProvider'); log('πŸ”΅ [CALL] Current callStatus: $callStatus', name: 'ChatProvider'); + // Get context for error dialogs + final context = navigatorKey.currentContext; + if (context == null) { + log('❌ [CALL] No context available', name: 'ChatProvider'); + return; + } + + // Check if already in a call if (callStatus != CallStatus.idle) { log('⚠️ [CALL] Cannot start call - already in a call (status: $callStatus)', name: 'ChatProvider'); - if (kDebugMode) { - print('⚠️ Cannot start call - already in a call'); - } + CallErrorHandler.showCallAlreadyInProgress(context); return; } if (sender == null) { log('❌ [CALL] Cannot start call - sender is null', name: 'ChatProvider'); - if (kDebugMode) { - print('⚠️ Cannot start call - sender is null'); - } + CallErrorHandler.showGenericError(context, 'Unable to start call. Please try again.'); return; } @@ -923,9 +924,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { currentCall = null; notifyListeners(); log('❌ [CALL] Microphone permission denied - aborting call', name: 'ChatProvider'); - if (kDebugMode) { - print('⚠️ Microphone permission denied'); - } + CallErrorHandler.showMicrophonePermissionDenied(context); return; } @@ -939,13 +938,21 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { currentCall = null; notifyListeners(); log('❌ [CALL] Camera permission denied - aborting call', name: 'ChatProvider'); - if (kDebugMode) { - print('⚠️ Camera permission denied'); - } + CallErrorHandler.showCameraPermissionDenied(context); return; } } + // Check SignalR connection before proceeding + if (chatHubConnection == null || chatHubConnection!.state != HubConnectionState.Connected) { + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + log('❌ [CALL] SignalR not connected', name: 'ChatProvider'); + CallErrorHandler.showSignalRNotConnected(context); + return; + } + // Create call session log('πŸ”΅ [CALL] Creating CallSession...', name: 'ChatProvider'); currentCall = CallSession( @@ -960,46 +967,128 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { log('βœ… [CALL] CallSession created - peerId: ${currentCall!.peerId}, peerName: ${currentCall!.peerName}', name: 'ChatProvider'); // Invoke SignalR CallUserAsync - log('πŸ”΅ [CALL] Checking SignalR connection status...', name: 'ChatProvider'); - if (chatHubConnection == null) { - log('❌ [CALL] SignalR connection is null!', name: 'ChatProvider'); - throw Exception('SignalR connection not established'); - } - - log('πŸ”΅ [CALL] SignalR connection state: ${chatHubConnection!.state}', name: 'ChatProvider'); log('πŸ”΅ [CALL] Invoking CallUserAsync with args:', name: 'ChatProvider'); log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); log(' - target: ${recipient.employeeNumber}', name: 'ChatProvider'); log(' - isVideoCall: $isVideo', name: 'ChatProvider'); - await chatHubConnection?.invoke( - 'CallUserAsync', - args: [ - sender!.employeeNumber ?? '', - recipient.employeeNumber ?? '', - isVideo, - ], - ); - log('βœ… [CALL] CallUserAsync invoked successfully', name: 'ChatProvider'); + try { + await chatHubConnection?.invoke( + 'CallUserAsync', + args: [ + sender!.employeeNumber ?? '', + recipient.employeeNumber ?? '', + isVideo, + ], + ); + log('βœ… [CALL] CallUserAsync invoked successfully', name: 'ChatProvider'); + } catch (e) { + log('❌ [CALL] Failed to invoke CallUserAsync: $e', name: 'ChatProvider'); + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + CallErrorHandler.showGenericError(context, 'Failed to start call. Please check your connection and try again.'); + return; + } callStatus = CallStatus.outgoingRinging; notifyListeners(); log('πŸ”΅ [CALL] Status changed to: outgoingRinging', name: 'ChatProvider'); - // Start 60s timeout - _callTimeoutTimer?.cancel(); - log('πŸ”΅ [CALL] Starting 60s timeout timer...', name: 'ChatProvider'); - _callTimeoutTimer = Timer(const Duration(seconds: 60), () { - log('⏱️ [CALL] Timeout timer fired', name: 'ChatProvider'); - if (callStatus == CallStatus.outgoingRinging) { - _handleCallTimeout(); + // TODO: Play outgoing ringtone (commented for now) + // await _playOutgoingRingtone(); + + // Initialize WebRTC now but DON'T send offer yet - wait for call to be accepted + log('πŸ”§ [CALL] Initializing WebRTC (offer will be sent after accept)...', name: 'ChatProvider'); + + try { + _webrtcService = WebRTCService(); + _setupWebRTCCallbacks(); + + if (callType == CallType.audio) { + await _webrtcService!.initializeForAudioCall(); + } else { + await _webrtcService!.initializeForVideoCall(); // Use video initialization for video calls } - }); + log('βœ… [CALL] WebRTC initialized, waiting for peer to accept...', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - log('βœ… [CALL] Call started successfully - callId: $callId', name: 'ChatProvider'); - if (kDebugMode) { - print('βœ… Call started: $callId'); + // Cleanup and notify peer + await chatHubConnection?.invoke('HangUpAsync', args: [ + sender!.employeeNumber ?? '', + recipient.employeeNumber ?? '', + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ]).catchError((err) { + log('❌ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider'); + }); + + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + + CallErrorHandler.showWebRTCInitFailed(context); + return; + } + + // Navigate to call screen + if (context != null && context.mounted) { + log('πŸ”΅ [CALL] Navigating to call screen...', name: 'ChatProvider'); + + // Double check currentCall is still valid before navigation + if (currentCall == null) { + log('❌ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider'); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => currentCall!.type == CallType.audio + ? const AudioCallPage() + : const VideoCallPage(), + ), + ).then((_) { + log('βœ… [CALL] Navigation completed', name: 'ChatProvider'); + }).catchError((e) { + log('❌ [CALL] Navigation error: $e', name: 'ChatProvider'); + }); + + log('βœ… [CALL] Navigated to call screen', name: 'ChatProvider'); + } else { + log('⚠️ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider'); + + // Alternative: Use WidgetsBinding to schedule navigation after frame + WidgetsBinding.instance.addPostFrameCallback((_) { + // Wait for app to fully come to foreground + Future.delayed(const Duration(milliseconds: 500), () { + final ctx = navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + log('πŸ”΅ [CALL] Navigating via post-frame callback...', name: 'ChatProvider'); + Navigator.of(ctx).push( + MaterialPageRoute( + builder: (context) => currentCall!.type == CallType.audio + ? const AudioCallPage() + : const VideoCallPage(), + ), + ).then((_) { + log('βœ… [CALL] Post-frame navigation completed', name: 'ChatProvider'); + }).catchError((e) { + log('❌ [CALL] Post-frame navigation error: $e', name: 'ChatProvider'); + }); + } else { + log('❌ [CALL] Still no context available - navigation failed', name: 'ChatProvider'); + // Last resort: dismiss CallKit and clean up + _callKitService.endCall(currentCall!.callId); + _teardownCall(); + } + }); + }); } + + // Wait for caller to send SDP offer via OnOfferAsync event + log('⏳ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider'); } catch (e, stackTrace) { log('❌ [CALL] Error starting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); if (kDebugMode) { @@ -1008,32 +1097,101 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { callStatus = CallStatus.idle; currentCall = null; notifyListeners(); + + // Show appropriate error dialog + if (context.mounted) { + if (e.toString().contains('WebRTC') || e.toString().contains('getUserMedia')) { + CallErrorHandler.showWebRTCInitFailed(context); + } else if (e.toString().contains('SignalR') || e.toString().contains('connection')) { + CallErrorHandler.showSignalRNotConnected(context); + } else { + CallErrorHandler.showGenericError(context, 'Failed to start call. Please try again.'); + } + } + } + } + + /// Play outgoing ringtone + Future _playOutgoingRingtone() async { + if (_isRingingPlaying) { + log('⚠️ [RINGTONE] Already playing', name: 'ChatProvider'); + return; + } + + try { + log('πŸ”” [RINGTONE] Starting outgoing ringtone...', name: 'ChatProvider'); + await _ringingPlayer.setAsset('assets/audio/outgoing_ringtone.mp3'); + await _ringingPlayer.setLoopMode(LoopMode.one); // Loop the ringtone + await _ringingPlayer.play(); + _isRingingPlaying = true; + log('βœ… [RINGTONE] Outgoing ringtone playing', name: 'ChatProvider'); + if (kDebugMode) { + print('πŸ”” Outgoing ringtone playing'); + } + } catch (e) { + log('❌ [RINGTONE] Error playing ringtone: $e', name: 'ChatProvider'); + if (kDebugMode) { + print('❌ Error playing ringtone: $e'); + } } } - /// Toggle microphone mute - void toggleMute() { - log('πŸ”΅ [CALL] toggleMute() called - current state: $isMuted', name: 'ChatProvider'); + /// Stop outgoing ringtone + Future _stopOutgoingRingtone() async { + if (!_isRingingPlaying) { + return; + } + + try { + log('πŸ”• [RINGTONE] Stopping outgoing ringtone...', name: 'ChatProvider'); + await _ringingPlayer.stop(); + await _ringingPlayer.pause(); + await _ringingPlayer.seek(Duration.zero); + _isRingingPlaying = false; + log('βœ… [RINGTONE] Ringtone stopped', name: 'ChatProvider'); + if (kDebugMode) { + print('πŸ”• Ringtone stopped'); + } + } catch (e) { + log('⚠️ [RINGTONE] Error stopping ringtone: $e', name: 'ChatProvider'); + // Force stop even on error + _isRingingPlaying = false; + } + } + + /// Toggle microphone mute/unmute + Future toggleMute() async { + log('🎀 [CALL CONTROL] toggleMute() called', name: 'ChatProvider'); + log('🎀 [CALL CONTROL] Current mute state: $isMuted', name: 'ChatProvider'); + + if (_webrtcService == null) { + log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); + return; + } + + // Toggle local mute state isMuted = !isMuted; notifyListeners(); - log('πŸ”΅ [CALL] Microphone ${isMuted ? "muted" : "unmuted"}', name: 'ChatProvider'); - // Notify peer via SignalR - if (currentCall != null && sender != null) { - log('πŸ”΅ [CALL] Invoking AudioToggle to notify peer', name: 'ChatProvider'); - log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); + log('βœ… [CALL CONTROL] Mute state changed to: $isMuted', name: 'ChatProvider'); - chatHubConnection?.invoke( - 'AudioToggle', - args: [sender!.employeeNumber ?? '', currentCall!.peerId], - ).then((_) { - log('βœ… [CALL] AudioToggle invoked successfully', name: 'ChatProvider'); - }).catchError((e) { - log('❌ [CALL] Error invoking AudioToggle: $e', name: 'ChatProvider'); - }); - } else { - log('⚠️ [CALL] Cannot notify peer - currentCall or sender is null', name: 'ChatProvider'); + // Update WebRTC audio track + _webrtcService!.setMicrophoneMuted(isMuted); + + // Notify peer via SignalR + if (chatHubConnection?.state == HubConnectionState.Connected && + sender != null && + currentCall != null) { + try { + log('πŸ”” [CALL CONTROL] Notifying peer of mute toggle...', name: 'ChatProvider'); + await chatHubConnection!.invoke( + 'AudioToggle', + args: [sender!.employeeNumber ?? '', currentCall!.peerId], + ); + log('βœ… [CALL CONTROL] Peer notified of mute toggle', name: 'ChatProvider'); + } catch (e) { + log('❌ [CALL CONTROL] Error notifying peer of mute: $e', name: 'ChatProvider'); + } } if (kDebugMode) { @@ -1041,147 +1199,166 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { } } - /// Toggle speakerphone - void toggleSpeaker() { - log('πŸ”΅ [CALL] toggleSpeaker() called - current state: $isSpeakerOn', name: 'ChatProvider'); + /// Toggle speakerphone on/off + Future toggleSpeaker() async { + log('πŸ”Š [CALL CONTROL] toggleSpeaker() called', name: 'ChatProvider'); + log('πŸ”Š [CALL CONTROL] Current speaker state: $isSpeakerOn', name: 'ChatProvider'); + + if (_webrtcService == null) { + log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); + return; + } + + // Toggle speaker state isSpeakerOn = !isSpeakerOn; notifyListeners(); - log('πŸ”΅ [CALL] Speaker ${isSpeakerOn ? "on" : "off"}', name: 'ChatProvider'); + + log('βœ… [CALL CONTROL] Speaker state changed to: $isSpeakerOn', name: 'ChatProvider'); + + // Update audio output + await _webrtcService!.setSpeakerphoneEnabled(isSpeakerOn); if (kDebugMode) { - print('πŸ”Š Speaker ${isSpeakerOn ? "on" : "off"}'); + print('πŸ”Š Speakerphone ${isSpeakerOn ? "on" : "off"}'); } } - /// Toggle camera (video only) - void toggleCamera() { - log('πŸ”΅ [CALL] toggleCamera() called - current state: $isCameraOn', name: 'ChatProvider'); + /// Toggle camera on/off (video calls only) + Future toggleCamera() async { + log('πŸ“Ή [CALL CONTROL] toggleCamera() called', name: 'ChatProvider'); + log('πŸ“Ή [CALL CONTROL] Current camera state: $isCameraOn', name: 'ChatProvider'); + + if (_webrtcService == null) { + log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); + return; + } + + if (currentCall?.type != CallType.video) { + log('⚠️ [CALL CONTROL] Not a video call', name: 'ChatProvider'); + return; + } + + // Toggle local camera state isCameraOn = !isCameraOn; notifyListeners(); - log('πŸ”΅ [CALL] Camera ${isCameraOn ? "on" : "off"}', name: 'ChatProvider'); - // Notify peer via SignalR - if (currentCall != null && sender != null) { - log('πŸ”΅ [CALL] Invoking CameraToggle to notify peer', name: 'ChatProvider'); - log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); + log('βœ… [CALL CONTROL] Camera state changed to: $isCameraOn', name: 'ChatProvider'); - chatHubConnection?.invoke( - 'CameraToggle', - args: [sender!.employeeNumber ?? '', currentCall!.peerId], - ).then((_) { - log('βœ… [CALL] CameraToggle invoked successfully', name: 'ChatProvider'); - }).catchError((e) { - log('❌ [CALL] Error invoking CameraToggle: $e', name: 'ChatProvider'); - }); - } else { - log('⚠️ [CALL] Cannot notify peer - currentCall or sender is null', name: 'ChatProvider'); - } + // Update WebRTC video track + _webrtcService!.setCameraEnabled(isCameraOn); - if (kDebugMode) { - print('πŸ“Ή Camera ${isCameraOn ? "on" : "off"}'); + // Notify peer via SignalR + if (chatHubConnection?.state == HubConnectionState.Connected && + sender != null && + currentCall != null) { + try { + log('πŸ”” [CALL CONTROL] Notifying peer of camera toggle...', name: 'ChatProvider'); + await chatHubConnection!.invoke( + 'CameraToggle', + args: [sender!.employeeNumber ?? '', currentCall!.peerId], + ); + log('βœ… [CALL CONTROL] Peer notified of camera toggle', name: 'ChatProvider'); + } catch (e) { + log('❌ [CALL CONTROL] Error notifying peer of camera toggle: $e', name: 'ChatProvider'); + } } - } - /// Switch between front and rear camera - void switchCamera() { - log('πŸ”΅ [CALL] switchCamera() called', name: 'ChatProvider'); if (kDebugMode) { - print('πŸ”„ Switch camera requested'); + print('πŸ“Ή Camera ${isCameraOn ? "on" : "off"}'); } - // Implementation will be added with WebRTC service - notifyListeners(); } - /// Switch from audio call to video call - Future switchToVideoCall() async { - log('πŸ”΅ [CALL] switchToVideoCall() called', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Current call type: ${currentCall?.type}', name: 'ChatProvider'); + /// Switch between front and rear camera (video calls only) + Future switchCamera() async { + log('πŸ”„ [CALL CONTROL] switchCamera() called', name: 'ChatProvider'); - if (currentCall == null || currentCall!.type == CallType.video) { - log('⚠️ [CALL] Cannot switch - currentCall is null or already video', name: 'ChatProvider'); + if (_webrtcService == null) { + log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); return; } - // Request camera permission - log('πŸ”΅ [CALL] Requesting camera permission for upgrade...', name: 'ChatProvider'); - final cameraStatus = await Permission.camera.request(); - log('πŸ”΅ [CALL] Camera permission status: ${cameraStatus.name}', name: 'ChatProvider'); + if (currentCall?.type != CallType.video) { + log('⚠️ [CALL CONTROL] Not a video call', name: 'ChatProvider'); + return; + } - if (!cameraStatus.isGranted) { - log('❌ [CALL] Camera permission denied - cannot upgrade to video', name: 'ChatProvider'); - if (kDebugMode) { - print('⚠️ Camera permission denied'); - } + if (!isCameraOn) { + log('⚠️ [CALL CONTROL] Camera is off, cannot switch', name: 'ChatProvider'); return; } - // Update call type (create new session to maintain immutability) - log('πŸ”΅ [CALL] Upgrading call to video...', name: 'ChatProvider'); - currentCall = CallSession( - callId: currentCall!.callId, - type: CallType.video, - direction: currentCall!.direction, - peerId: currentCall!.peerId, - peerName: currentCall!.peerName, - peerAvatar: currentCall!.peerAvatar, - startTime: currentCall!.startTime, - sessionId: currentCall!.sessionId, - sdpOffer: currentCall!.sdpOffer, - sdpAnswer: currentCall!.sdpAnswer, - ); - isCameraOn = true; - notifyListeners(); - log('βœ… [CALL] Successfully switched to video call', name: 'ChatProvider'); + try { + await _webrtcService!.switchCamera(); + log('βœ… [CALL CONTROL] Camera switched', name: 'ChatProvider'); - if (kDebugMode) { - print('πŸ“Ή Switched to video call'); + if (kDebugMode) { + print('πŸ”„ Camera switched'); + } + } catch (e) { + log('❌ [CALL CONTROL] Error switching camera: $e', name: 'ChatProvider'); + if (kDebugMode) { + print('❌ Error switching camera: $e'); + } } } - /// End the current call + /// End the current call (hang up) Future hangUp() async { - log('πŸ”΅ [CALL] hangUp() called', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Current status: $callStatus', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); + log('πŸ“ž [CALL CONTROL] hangUp() called', name: 'ChatProvider'); + log('πŸ“ž [CALL CONTROL] Current status: $callStatus', name: 'ChatProvider'); + log('πŸ“ž [CALL CONTROL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); - if (currentCall == null || callStatus == CallStatus.idle) { - log('⚠️ [CALL] No active call to hang up', name: 'ChatProvider'); + if (currentCall == null) { + log('⚠️ [CALL CONTROL] No active call to hang up', name: 'ChatProvider'); return; } + // End CallKit UI first try { - // Invoke SignalR HangUpAsync - log('πŸ”΅ [CALL] Invoking HangUpAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: ${referenceID?.toString() ?? "null"}', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id?.toString() ?? "null"}', name: 'ChatProvider'); + await _callKitService.endCall(currentCall!.callId); + log('βœ… [CallKit] Native call UI ended', name: 'ChatProvider'); + } catch (e) { + log('⚠️ [CallKit] Error ending native UI: $e', name: 'ChatProvider'); + } - await chatHubConnection?.invoke( - 'HangUpAsync', - args: [ - sender?.employeeNumber ?? '', - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ); - log('βœ… [CALL] HangUpAsync invoked successfully', name: 'ChatProvider'); + // Stop ringtone if playing + await _stopOutgoingRingtone(); - if (kDebugMode) { - print('πŸ“ž Call ended'); + try { + // Invoke SignalR HangUpAsync + if (chatHubConnection?.state == HubConnectionState.Connected && sender != null) { + log('πŸ”” [CALL CONTROL] Invoking HangUpAsync with args:', name: 'ChatProvider'); + log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); + log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); + log(' - moduleCode: $moduleID', name: 'ChatProvider'); + log(' - referenceId: $referenceID', name: 'ChatProvider'); + log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider'); + + await chatHubConnection!.invoke( + 'HangUpAsync', + args: [ + sender!.employeeNumber ?? '', + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + log('βœ… [CALL CONTROL] HangUpAsync invoked successfully', name: 'ChatProvider'); } } catch (e, stackTrace) { - log('❌ [CALL] Error invoking HangUpAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + log('❌ [CALL CONTROL] Error invoking HangUpAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); if (kDebugMode) { - print('⚠️ Error ending call: $e'); + print('⚠️ Error hanging up call: $e'); } } + // Teardown call resources _teardownCall(); + + if (kDebugMode) { + print('πŸ“ž Call ended'); + } } /// Handle call timeout (60s no answer) @@ -1225,12 +1402,36 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { log('πŸ”΅ [CALL] Previous status: $callStatus', name: 'ChatProvider'); log('πŸ”΅ [CALL] Call duration: ${callDuration.inSeconds}s', name: 'ChatProvider'); + // End CallKit UI if we have an active call + if (currentCall != null) { + try { + _callKitService.endCall(currentCall!.callId); + log('βœ… [CallKit] Native call UI ended in teardown', name: 'ChatProvider'); + } catch (e) { + log('⚠️ [CallKit] Error ending native UI in teardown: $e', name: 'ChatProvider'); + } + } + + // Stop outgoing ringtone if playing + _stopOutgoingRingtone(); + _callTimeoutTimer?.cancel(); log('πŸ”΅ [CALL] Timeout timer cancelled', name: 'ChatProvider'); _callDurationTimer?.cancel(); log('πŸ”΅ [CALL] Duration timer cancelled', name: 'ChatProvider'); + // Dispose WebRTC service to release media resources + if (_webrtcService != null) { + log('πŸ”§ [CALL] Disposing WebRTC service...', name: 'ChatProvider'); + _webrtcService!.dispose().then((_) { + log('βœ… [CALL] WebRTC service disposed', name: 'ChatProvider'); + }).catchError((e) { + log('⚠️ [CALL] Error disposing WebRTC service: $e', name: 'ChatProvider'); + }); + _webrtcService = null; + } + callStatus = CallStatus.idle; currentCall = null; callDuration = Duration.zero; @@ -1239,6 +1440,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { isCameraOn = true; isPeerMuted = false; isPeerCameraOn = true; + _remoteMediaStream = null; notifyListeners(); log('βœ… [CALL] Call resources cleaned up - status reset to idle', name: 'ChatProvider'); @@ -1268,96 +1470,243 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { /// Accept incoming call Future acceptCall() async { log('πŸ”΅ [CALL] acceptCall() called', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Current status: $callStatus', name: 'ChatProvider'); log('πŸ”΅ [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); + log('πŸ”΅ [CALL] Current status: $callStatus', name: 'ChatProvider'); if (currentCall == null || callStatus != CallStatus.incomingRinging) { - log('⚠️ [CALL] Cannot accept - invalid state (call: ${currentCall == null ? "null" : "exists"}, status: $callStatus)', name: 'ChatProvider'); + log('⚠️ [CALL] Cannot accept - no incoming call or wrong status', name: 'ChatProvider'); return; } - // Request permissions - log('πŸ”΅ [CALL] Requesting microphone permission...', name: 'ChatProvider'); - final micStatus = await Permission.microphone.request(); - log('πŸ”΅ [CALL] Microphone permission: ${micStatus.name}', name: 'ChatProvider'); - - if (!micStatus.isGranted) { - log('❌ [CALL] Microphone permission denied - declining call', name: 'ChatProvider'); - await declineCall('permission_denied'); - return; + // IMPORTANT: Dismiss CallKit UI first + try { + await _callKitService.setCallConnected(currentCall!.callId); + log('βœ… [CallKit] Native UI updated to connected state', name: 'ChatProvider'); + } catch (e) { + log('⚠️ [CallKit] Error updating to connected state: $e', name: 'ChatProvider'); } - if (currentCall!.type == CallType.video) { - log('πŸ”΅ [CALL] Video call - requesting camera permission...', name: 'ChatProvider'); - final cameraStatus = await Permission.camera.request(); - log('πŸ”΅ [CALL] Camera permission: ${cameraStatus.name}', name: 'ChatProvider'); + // Get context - we'll retry if null + BuildContext? context = navigatorKey.currentContext; - if (!cameraStatus.isGranted) { - log('❌ [CALL] Camera permission denied - declining call', name: 'ChatProvider'); + try { + // Check microphone permission + log('πŸ”΅ [CALL] Checking microphone permission...', name: 'ChatProvider'); + final micStatus = await Permission.microphone.request(); + if (!micStatus.isGranted) { + log('❌ [CALL] Microphone permission denied - declining call', name: 'ChatProvider'); await declineCall('permission_denied'); + if (context != null && context.mounted) { + CallErrorHandler.showMicrophonePermissionDenied(context); + } return; } - } - try { - // Invoke SignalR AnswerCallAsync - log('πŸ”΅ [CALL] Invoking AnswerCallAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: ${referenceID?.toString() ?? "null"}', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id?.toString() ?? "null"}', name: 'ChatProvider'); + // Check camera permission for video calls + if (currentCall!.type == CallType.video) { + log('πŸ”΅ [CALL] Checking camera permission...', name: 'ChatProvider'); + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + log('❌ [CALL] Camera permission denied - declining call', name: 'ChatProvider'); + await declineCall('permission_denied'); + if (context != null && context.mounted) { + CallErrorHandler.showCameraPermissionDenied(context); + } + return; + } + } - await chatHubConnection?.invoke( - 'AnswerCallAsync', - args: [ - sender?.employeeNumber ?? '', - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ); - log('βœ… [CALL] AnswerCallAsync invoked successfully', name: 'ChatProvider'); + // Cancel timeout timer + _callTimeoutTimer?.cancel(); + // Update status to connecting callStatus = CallStatus.connecting; notifyListeners(); log('πŸ”΅ [CALL] Status changed to: connecting', name: 'ChatProvider'); - if (kDebugMode) { - print('βœ… Call accepted'); + // Invoke AnswerCallAsync on SignalR FIRST + if (chatHubConnection?.state != HubConnectionState.Connected) { + log('❌ [CALL] SignalR not connected', name: 'ChatProvider'); + _teardownCall(); + if (context != null && context.mounted) { + CallErrorHandler.showSignalRNotConnected(context); + } + return; } - } catch (e, stackTrace) { - log('❌ [CALL] Error accepting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('❌ Error accepting call: $e'); + + final myEmployeeNumber = sender?.employeeNumber; + if (myEmployeeNumber == null || myEmployeeNumber.isEmpty) { + log('❌ [CALL] No employee number found', name: 'ChatProvider'); + _teardownCall(); + if (context != null && context.mounted) { + CallErrorHandler.showGenericError(context, 'Unable to accept call. Please try again.'); + } + return; } - _teardownCall(); - } - } - /// Decline incoming call - Future declineCall(String reason) async { - log('πŸ”΅ [CALL] declineCall() called with reason: $reason', name: 'ChatProvider'); - log('πŸ”΅ [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); + log('πŸ”΅ [CALL] Invoking AnswerCallAsync with args:', name: 'ChatProvider'); + log(' - source: $myEmployeeNumber', name: 'ChatProvider'); + log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - if (currentCall == null) { - log('⚠️ [CALL] No call to decline', name: 'ChatProvider'); - return; + try { + await chatHubConnection!.invoke( + 'AnswerCallAsync', + args: [ + myEmployeeNumber, + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + log('βœ… [CALL] AnswerCallAsync invoked successfully', name: 'ChatProvider'); + } catch (e) { + log('❌ [CALL] Failed to invoke AnswerCallAsync: $e', name: 'ChatProvider'); + _teardownCall(); + if (context != null && context.mounted) { + CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.'); + } + return; + } + + // Initialize WebRTC and setup callbacks + log('πŸ”§ [CALL] Initializing WebRTC for incoming call...', name: 'ChatProvider'); + + try { + _webrtcService = WebRTCService(); + _setupWebRTCCallbacks(); + + if (currentCall!.type == CallType.audio) { + await _webrtcService!.initializeForAudioCall(); + } else { + await _webrtcService!.initializeForVideoCall(); + } + + log('βœ… [CALL] WebRTC initialized', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + + // Cleanup and notify peer + await chatHubConnection?.invoke('HangUpAsync', args: [ + myEmployeeNumber, + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ]).catchError((err) { + log('❌ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider'); + }); + + _teardownCall(); + // Retry getting context + context = navigatorKey.currentContext; + if (context != null && context.mounted) { + CallErrorHandler.showWebRTCInitFailed(context); + } + return; + } + + // Wait a moment for the app to come to foreground if needed + await Future.delayed(const Duration(milliseconds: 500)); + + // Retry getting context after delay + context = navigatorKey.currentContext; + + // Navigate to call screen + if (context != null && context.mounted) { + log('πŸ”΅ [CALL] Navigating to call screen...', name: 'ChatProvider'); + + // Double check currentCall is still valid before navigation + if (currentCall == null) { + log('❌ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider'); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => currentCall!.type == CallType.audio + ? const AudioCallPage() + : const VideoCallPage(), + ), + ).then((_) { + log('βœ… [CALL] Navigation completed', name: 'ChatProvider'); + }).catchError((e) { + log('❌ [CALL] Navigation error: $e', name: 'ChatProvider'); + }); + + log('βœ… [CALL] Navigated to call screen', name: 'ChatProvider'); + } else { + log('⚠️ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider'); + + // Alternative: Use WidgetsBinding to schedule navigation after frame + WidgetsBinding.instance.addPostFrameCallback((_) { + // Wait for app to fully come to foreground + Future.delayed(const Duration(milliseconds: 500), () { + final ctx = navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + log('πŸ”΅ [CALL] Navigating via post-frame callback...', name: 'ChatProvider'); + Navigator.of(ctx).push( + MaterialPageRoute( + builder: (context) => currentCall!.type == CallType.audio + ? const AudioCallPage() + : const VideoCallPage(), + ), + ).then((_) { + log('βœ… [CALL] Post-frame navigation completed', name: 'ChatProvider'); + }).catchError((e) { + log('❌ [CALL] Post-frame navigation error: $e', name: 'ChatProvider'); + }); + } else { + log('❌ [CALL] Still no context available - navigation failed', name: 'ChatProvider'); + // Last resort: dismiss CallKit and clean up + _callKitService.endCall(currentCall!.callId); + _teardownCall(); + } + }); + }); + } + + // Wait for caller to send SDP offer via OnOfferAsync event + log('⏳ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL] Error accepting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + if (kDebugMode) { + print('⚠️ Error accepting call: $e'); + } + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + + context = navigatorKey.currentContext; + if (context != null && context.mounted) { + CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.'); + } + } + } + + /// Decline an incoming call + Future declineCall(String reason) async { + log('πŸ”΄ [CALL] declineCall() called - reason: $reason', name: 'ChatProvider'); + log('πŸ”΄ [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); + log('πŸ”΄ [CALL] Current status: $callStatus', name: 'ChatProvider'); + + if (currentCall == null || callStatus != CallStatus.incomingRinging) { + log('⚠️ [CALL] Cannot decline - no incoming call or wrong status', name: 'ChatProvider'); + return; } try { // Invoke SignalR CallDeclinedAsync - log('πŸ”΅ [CALL] Invoking CallDeclinedAsync with args:', name: 'ChatProvider'); + log('πŸ”΄ [CALL] Invoking CallDeclinedAsync with args:', name: 'ChatProvider'); log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: ${referenceID?.toString() ?? "null"}', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id?.toString() ?? "null"}', name: 'ChatProvider'); + log(' - referenceId: $referenceID', name: 'ChatProvider'); + log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider'); await chatHubConnection?.invoke( 'CallDeclinedAsync', - args: [ + args: [ sender?.employeeNumber ?? '', currentCall!.peerId, moduleID.toString(), @@ -1367,11 +1716,9 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { ); log('βœ… [CALL] CallDeclinedAsync invoked successfully', name: 'ChatProvider'); - if (kDebugMode) { - print('πŸ“ž Call declined: $reason'); - } + await _stopOutgoingRingtone(); } catch (e, stackTrace) { - log('❌ [CALL] Error declining call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + log('❌ [CALL] Error invoking CallDeclinedAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); if (kDebugMode) { print('⚠️ Error declining call: $e'); } @@ -1380,286 +1727,518 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { _teardownCall(); } - /// Register call-related SignalR event handlers + /// Register all call event handlers from SignalR void _registerCallHandlers() { + if (chatHubConnection == null) { + log('⚠️ [CALL] Cannot register call handlers - connection is null', name: 'ChatProvider'); + return; + } + log('═══════════════════════════════════════════', name: 'ChatProvider'); log('πŸ“ž [CALL] Starting call handler registration...', name: 'ChatProvider'); - if (chatHubConnection == null) { - log('❌ [CALL] Cannot register handlers - chatHubConnection is null', name: 'ChatProvider'); + chatHubConnection!.on('OnIncomingCallAsync', _handleIncomingCall); + chatHubConnection!.on('OnCallAcceptedAsync', _handleCallAccepted); + chatHubConnection!.on('OnCallDeclinedAsync', _handleCallDeclined); + chatHubConnection!.on('OnHangUpAsync', _handleHangUp); + chatHubConnection!.on('OnOfferAsync', _handleOffer); + chatHubConnection!.on('OnAnswerOfferAsync', _handleAnswer); + chatHubConnection!.on('OnIceCandidateAsync', _handleIceCandidate); + chatHubConnection!.on('OnAudioToggle', _handleAudioToggle); + chatHubConnection!.on('OnCameraToggle', _handleCameraToggle); + + _callHandlersRegistered = true; + notifyListeners(); + + log('βœ… [CALL] All call handlers registered successfully', name: 'ChatProvider'); + log('═══════════════════════════════════════════', name: 'ChatProvider'); + + if (kDebugMode) { + print('βœ… Call handlers registered'); + } + } + + /// Setup WebRTC callbacks + void _setupWebRTCCallbacks() { + if (_webrtcService == null) { + log('⚠️ [CALL] Cannot setup callbacks - WebRTC service is null', name: 'ChatProvider'); return; } - // NEW: Add a GLOBAL catch-all handler to see ALL SignalR events in debug mode - if (kDebugMode) { - log('πŸ” [DEBUG] Setting up GLOBAL event interceptor to log ALL incoming SignalR messages', name: 'ChatProvider'); - - // Store the original method handlers - final originalHandlers = {}; - - // Register a generic listener for common call event patterns - final allPossibleCallEvents = [ - 'OnIncomingCallAsync', - 'OnIncomingCall', - 'IncomingCall', - 'OnCallAcceptedAsync', - 'OnCallAccepted', - 'CallAccepted', - 'OnCallDeclinedAsync', - 'OnCallDeclined', - 'CallDeclined', - 'OnHangUpAsync', - 'OnHangUp', - 'HangUp', - 'OnAudioToggle', - 'AudioToggle', - 'OnCameraToggle', - 'CameraToggle', - 'CallUserAsync', // Echo back - 'OnCallStarted', // Alternative - 'OnCallRinging', // Alternative - ]; - - for (final eventName in allPossibleCallEvents) { - chatHubConnection!.on(eventName, (args) { - log('πŸ””πŸ””πŸ”” [GLOBAL DEBUG] Received SignalR event: "$eventName"', name: 'ChatProvider'); - log(' Args: $args', name: 'ChatProvider'); - log(' Args type: ${args?.runtimeType}', name: 'ChatProvider'); - if (args != null && args.isNotEmpty) { - log(' First arg: ${args.first}', name: 'ChatProvider'); - log(' First arg type: ${args.first?.runtimeType}', name: 'ChatProvider'); - } + log('πŸ”§ [CALL] Setting up WebRTC callbacks...', name: 'ChatProvider'); + + _webrtcService!.onIceCandidate = (RTCIceCandidate candidate) { + log('🧊 [CALL] Local ICE candidate generated', name: 'ChatProvider'); + + if (chatHubConnection?.state == HubConnectionState.Connected && sender != null && currentCall != null) { + final candidateJson = jsonEncode({ + 'candidate': candidate.candidate, + 'sdpMid': candidate.sdpMid, + 'sdpMLineIndex': candidate.sdpMLineIndex, }); + + chatHubConnection!.invoke( + 'IceCandidateAsync', + args: [currentCall!.peerId, candidateJson, currentCall!.sessionId ?? ''], + ); } - } + }; - // Incoming call - log('πŸ”΅ [CALL] Registering: OnIncomingCallAsync', name: 'ChatProvider'); - chatHubConnection!.on("OnIncomingCallAsync", _onIncomingCall); + _webrtcService!.onRemoteStream = (MediaStream stream) { + log('πŸ“‘ [CALL] Remote stream received in ChatProvider callback', name: 'ChatProvider'); + log('πŸ“‘ [CALL] Remote stream has ${stream.getVideoTracks().length} video tracks', name: 'ChatProvider'); + log('πŸ“‘ [CALL] Remote stream has ${stream.getAudioTracks().length} audio tracks', name: 'ChatProvider'); + _remoteMediaStream = stream; - // Call accepted - log('πŸ”΅ [CALL] Registering: OnCallAcceptedAsync', name: 'ChatProvider'); - chatHubConnection!.on("OnCallAcceptedAsync", _onCallAccepted); + // Notify listeners to update UI when remote stream is received + notifyListeners(); + log('βœ… [CALL] UI notified about remote stream', name: 'ChatProvider'); + }; - // Call declined - log('πŸ”΅ [CALL] Registering: OnCallDeclinedAsync', name: 'ChatProvider'); - chatHubConnection!.on("OnCallDeclinedAsync", _onCallDeclined); + _webrtcService!.onIceConnectionStateChange = (RTCIceConnectionState state) { + log('πŸ”— [CALL] ICE state: ${state.toString()}', name: 'ChatProvider'); - // Call ended - log('πŸ”΅ [CALL] Registering: OnHangUpAsync', name: 'ChatProvider'); - chatHubConnection!.on("OnHangUpAsync", _onCallEnded); + if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) { + _stopOutgoingRingtone(); - // Peer audio toggle - log('πŸ”΅ [CALL] Registering: OnAudioToggle', name: 'ChatProvider'); - chatHubConnection!.on("OnAudioToggle", _onPeerAudioToggle); + // Cancel connection timeout since we're now connected + _callTimeoutTimer?.cancel(); - // Peer camera toggle - log('πŸ”΅ [CALL] Registering: OnCameraToggle', name: 'ChatProvider'); - chatHubConnection!.on("OnCameraToggle", _onPeerCameraToggle); + if (callStatus != CallStatus.connected) { + callStatus = CallStatus.connected; + notifyListeners(); + _startCallDurationTimer(); + } + } + else if (state == RTCIceConnectionState.RTCIceConnectionStateChecking) { + log('πŸ” [CALL] ICE checking - establishing connection...', name: 'ChatProvider'); + // Start a longer timeout for connection establishment (30 seconds) + _startConnectionTimeout(); + } + else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) { + log('❌ [CALL] ICE connection failed - ending call', name: 'ChatProvider'); + // Connection failed, terminate the call + _handleConnectionFailure(); + } + else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) { + log('⚠️ [CALL] ICE connection disconnected - waiting for reconnection', name: 'ChatProvider'); + // Start a timeout to end call if it doesn't reconnect within 10 seconds + _startReconnectionTimeout(); + } + }; - log('βœ… [CALL] All call handlers registered successfully', name: 'ChatProvider'); - log('πŸ“ž [CALL] Ready to receive: OnIncomingCallAsync, OnCallAcceptedAsync, OnCallDeclinedAsync, OnHangUpAsync', name: 'ChatProvider'); - log('πŸ“ž [CALL] My Employee Number: ${sender?.employeeNumber ?? "NOT SET YET"}', name: 'ChatProvider'); - log('πŸ“ž [CALL] My User ID: ${chatLoginResponse?.userId ?? "NOT SET"}', name: 'ChatProvider'); - log('πŸ“ž [CALL] SignalR Connection ID: ${chatHubConnection?.connectionId ?? "NULL"}', name: 'ChatProvider'); - log('═══════════════════════════════════════════', name: 'ChatProvider'); + log('βœ… [CALL] WebRTC callbacks setup complete', name: 'ChatProvider'); + } - if (kDebugMode) { - print('βœ… Call handlers registered - watching for ALL call events'); - } + /// Create and send SDP offer + Future _createAndSendOffer() async { + try { + log('πŸ”§ [CALL] Creating SDP offer...', name: 'ChatProvider'); - _callHandlersRegistered = true; - } + if (_webrtcService == null || currentCall == null) { + throw Exception('WebRTC service or call session is null'); + } - /// Handle incoming call event - void _onIncomingCall(List? args) { - log('πŸ“ž [CALL EVENT] OnIncomingCallAsync received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Current status: $callStatus', name: 'ChatProvider'); + final offer = await _webrtcService!.createOffer(); + log('βœ… [CALL] SDP offer created', name: 'ChatProvider'); - if (args == null || args.isEmpty) { - log('⚠️ [CALL EVENT] OnIncomingCallAsync - args is null or empty', name: 'ChatProvider'); - return; + await chatHubConnection!.invoke( + 'OfferAsync', + args: [currentCall!.peerId, offer.sdp ?? '', currentCall!.callId], + ); + log('βœ… [CALL] SDP offer sent', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL] Error creating/sending offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + _teardownCall(); } + } - try { - final data = args.first as Map; - log('πŸ”΅ [CALL EVENT] Parsed data: $data', name: 'ChatProvider'); + // ==================== CALL EVENT HANDLERS ==================== - final callerId = data['sourceUserId'] as String?; - final callerName = data['userName'] as String? ?? 'Unknown'; - final isVideo = data['isVideoCall'] as bool? ?? false; + void _handleIncomingCall(List? args) async { + log('πŸ“ž [CALL EVENT] OnIncomingCallAsync received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Parsed values:', name: 'ChatProvider'); - log(' - callerId: $callerId', name: 'ChatProvider'); - log(' - callerName: $callerName', name: 'ChatProvider'); - log(' - isVideoCall: $isVideo', name: 'ChatProvider'); + if (args == null || args.isEmpty) return; - // Check if already in a call - if (callStatus != CallStatus.idle) { - log('⚠️ [CALL EVENT] Already in a call (status: $callStatus) - auto-rejecting with busy', name: 'ChatProvider'); - - // Auto-reject with busy status - log('πŸ”΅ [CALL EVENT] Invoking CallDeclinedAsync (busy) with args:', name: 'ChatProvider'); - log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); - log(' - target: $callerId', name: 'ChatProvider'); + try { + final callData = args[0] as Map; + final callerId = callData['sourceUserId'] as String?; + final callerName = callData['userName'] as String? ?? 'Unknown'; + final isVideoCall = callData['isVideoCall'] as bool? ?? false; + final sdpOffer = callData['sdpOffer'] as String?; - chatHubConnection?.invoke( - 'CallDeclinedAsync', - args: [ - sender?.employeeNumber ?? '', - callerId ?? '', - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ).then((_) { - log('βœ… [CALL EVENT] Auto-rejection sent successfully', name: 'ChatProvider'); - }).catchError((e) { - log('❌ [CALL EVENT] Error sending auto-rejection: $e', name: 'ChatProvider'); - }); + // Check if already in a call - decline if busy + if (callStatus != CallStatus.idle) { + log('⚠️ [CALL] Already in a call, declining incoming call', name: 'ChatProvider'); + chatHubConnection?.invoke('CallDeclinedAsync', args: [ + sender?.employeeNumber ?? '', + callerId ?? '', + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ]); return; } + // Generate unique call ID + final callId = const Uuid().v4(); + // Create call session - log('πŸ”΅ [CALL EVENT] Creating incoming CallSession...', name: 'ChatProvider'); currentCall = CallSession( - callId: const Uuid().v4(), - type: isVideo ? CallType.video : CallType.audio, + callId: callId, + type: isVideoCall ? CallType.video : CallType.audio, direction: CallDirection.incoming, peerId: callerId ?? '', peerName: callerName, peerAvatar: null, startTime: DateTime.now(), + sdpOffer: sdpOffer, ); - log('βœ… [CALL EVENT] CallSession created - callId: ${currentCall!.callId}', name: 'ChatProvider'); callStatus = CallStatus.incomingRinging; notifyListeners(); - log('πŸ”΅ [CALL EVENT] Status changed to: incomingRinging', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Incoming ${isVideo ? "video" : "audio"} call from $callerName processed', name: 'ChatProvider'); - - // NEW: Show incoming call dialog using global navigator - log('πŸ”΅ [CALL EVENT] Showing incoming call dialog...', name: 'ChatProvider'); - final context = navigatorKey.currentContext; - if (context != null && currentCall != null) { - // Use a post-frame callback to ensure we're not in the middle of a build - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => IncomingCallDialog(call: currentCall!), - ); - log('βœ… [CALL EVENT] Incoming call dialog shown', name: 'ChatProvider'); - } - }); - } else { - log('⚠️ [CALL EVENT] Cannot show dialog - context is null or call is null', name: 'ChatProvider'); + + log('πŸ“ž [CallKit] Showing native incoming call UI...', name: 'ChatProvider'); + log(' Caller: $callerName', name: 'ChatProvider'); + log(' Caller ID: $callerId', name: 'ChatProvider'); + log(' Video: $isVideoCall', name: 'ChatProvider'); + log(' Call ID: $callId', name: 'ChatProvider'); + + // Initialize CallKit if not already initialized + if (!_callKitInitialized) { + await _initializeCallKit(); } - if (kDebugMode) { - print('πŸ“ž Incoming ${isVideo ? "video" : "audio"} call from $callerName'); + // Show native incoming call UI using CallKit + try { + await _callKitService.showIncomingCall( + callId: callId, + callerName: callerName, + callerNumber: callerId ?? '', + callerAvatar: null, // TODO: Get avatar from participant data if available + isVideo: isVideoCall, + extra: { + 'peerId': callerId ?? '', + 'moduleId': moduleID.toString(), + 'referenceId': referenceID?.toString() ?? '', + 'conversationId': chatParticipantModel?.id?.toString() ?? '', + }, + ); + log('βœ… [CallKit] Native incoming call UI displayed', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CallKit] Error showing native UI, falling back to dialog: $e', + name: 'ChatProvider', error: e, stackTrace: stackTrace); + + // Fallback to custom dialog if CallKit fails + final context = navigatorKey.currentContext; + if (context != null) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => IncomingCallDialog(call: currentCall!), + ); + } } + + // Start call timeout timer (30 seconds for incoming calls) + _callTimeoutTimer?.cancel(); + _callTimeoutTimer = Timer(const Duration(seconds: 30), () { + if (callStatus == CallStatus.incomingRinging) { + log('⏱️ [CALL] Incoming call timeout - no answer after 30s', name: 'ChatProvider'); + + // End CallKit UI + _callKitService.endCall(callId); + + // Cleanup + _teardownCall(); + } + }); + log('⏱️ [CALL] Incoming call timeout started (30s)', name: 'ChatProvider'); + } catch (e, stackTrace) { log('❌ [CALL EVENT] Error handling incoming call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('❌ Error handling incoming call: $e'); - } + + // Cleanup on error + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); } } - /// Handle call accepted event - void _onCallAccepted(List? args) { - log('βœ… [CALL EVENT] OnCallAcceptedAsync received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Current status: $callStatus', name: 'ChatProvider'); + void _handleCallAccepted(List? args) { + log('πŸ“ž [CALL EVENT] OnCallAcceptedAsync received', name: 'ChatProvider'); - if (callStatus == CallStatus.outgoingRinging) { - log('πŸ”΅ [CALL EVENT] Call was accepted by peer - stopping timeout timer', name: 'ChatProvider'); - _callTimeoutTimer?.cancel(); + if (callStatus != CallStatus.outgoingRinging) return; - callStatus = CallStatus.connecting; - notifyListeners(); - log('πŸ”΅ [CALL EVENT] Status changed to: connecting', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Call accepted - proceeding with connection', name: 'ChatProvider'); + _callTimeoutTimer?.cancel(); + callStatus = CallStatus.connecting; + notifyListeners(); - if (kDebugMode) { - print('βœ… Call was accepted'); - } - } else { - log('⚠️ [CALL EVENT] Received OnCallAcceptedAsync but status is not outgoingRinging: $callStatus', name: 'ChatProvider'); - } + _createAndSendOffer(); } - /// Handle call declined event - void _onCallDeclined(List? args) { + void _handleCallDeclined(List? args) { log('πŸ“ž [CALL EVENT] OnCallDeclinedAsync received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Current status: $callStatus', name: 'ChatProvider'); + _teardownCall(); + } + + void _handleHangUp(List? args) { + log('πŸ“ž [CALL EVENT] OnHangUpAsync received', name: 'ChatProvider'); + _teardownCall(); + } + + void _handleOffer(List? args) async { + log('πŸ“ž [CALL EVENT] OnOfferAsync received', name: 'ChatProvider'); + + if (args == null || args.isEmpty) return; + + try { + final offerSdp = args[0] as String?; + if (offerSdp == null || _webrtcService == null) return; + + final answer = await _webrtcService!.createAnswer(offerSdp); + + await chatHubConnection!.invoke( + 'AnswerOfferAsync', + args: [currentCall!.peerId, answer.sdp ?? '', currentCall!.callId], + ); + log('βœ… [CALL EVENT] SDP answer sent', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL EVENT] Error handling offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + } + } + + void _handleAnswer(List? args) async { + log('πŸ“ž [CALL EVENT] OnAnswerOfferAsync received', name: 'ChatProvider'); + + if (args == null || args.isEmpty) return; + + try { + final answerSdp = args[0] as String?; + if (answerSdp == null || _webrtcService == null) return; + + await _webrtcService!.setRemoteAnswer(answerSdp); + log('βœ… [CALL EVENT] Remote answer set', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CALL EVENT] Error handling answer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + } + } + + void _handleIceCandidate(List? args) async { + log('πŸ“ž [CALL EVENT] OnIceCandidateAsync received', name: 'ChatProvider'); if (args == null || args.isEmpty) { - log('⚠️ [CALL EVENT] OnCallDeclinedAsync - args is null or empty', name: 'ChatProvider'); + log('⚠️ [CALL EVENT] ICE candidate args are null or empty', name: 'ChatProvider'); return; } try { - final reason = args.first as String? ?? 'declined'; - log('πŸ”΅ [CALL EVENT] Decline reason: $reason', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Call was declined by peer', name: 'ChatProvider'); + final candidateJson = args[0] as String?; + if (candidateJson == null) { + log('⚠️ [CALL EVENT] ICE candidate JSON is null', name: 'ChatProvider'); + return; + } - if (kDebugMode) { - print('πŸ“ž Call declined: $reason'); + if (_webrtcService == null) { + log('⚠️ [CALL EVENT] WebRTC service not initialized yet, ignoring ICE candidate', name: 'ChatProvider'); + return; } - _teardownCall(); + final candidateData = jsonDecode(candidateJson) as Map; + final candidate = RTCIceCandidate( + candidateData['candidate'] as String?, + candidateData['sdpMid'] as String?, + candidateData['sdpMLineIndex'] as int?, + ); + + log('🧊 [CALL EVENT] Parsed ICE candidate: ${candidateData['candidate']}', name: 'ChatProvider'); + await _webrtcService!.addIceCandidate(candidate); + log('βœ… [CALL EVENT] ICE candidate added successfully', name: 'ChatProvider'); } catch (e, stackTrace) { - log('❌ [CALL EVENT] Error handling call declined: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('❌ Error handling call declined: $e'); + log('❌ [CALL EVENT] Error handling ICE candidate: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); + // Don't rethrow - ICE candidate errors shouldn't crash the app + } + } + + void _handleAudioToggle(List? args) { + log('πŸ“ž [CALL EVENT] OnAudioToggle received', name: 'ChatProvider'); + isPeerMuted = !isPeerMuted; + notifyListeners(); + } + + void _handleCameraToggle(List? args) { + log('πŸ“ž [CALL EVENT] OnCameraToggle received', name: 'ChatProvider'); + isPeerCameraOn = !isPeerCameraOn; + notifyListeners(); + } + + void _onCallHistoryUpdated(List? args) async { + log('πŸ“ž [CALL HISTORY] OnCallHistoryUpdated received', name: 'ChatProvider'); + + try { + if (sender != null && recipient != null) { + userChatHistory = await ChatApiClient().loadChatHistory( + moduleID, + referenceID ?? 0, + sender!.employeeNumber ?? '', + recipient!.employeeNumber ?? '', + ); + + chatResponseList = userChatHistory ?? []; + chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!)); + notifyListeners(); } + } catch (e, stackTrace) { + log('❌ [CALL HISTORY] Error reloading chat history: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); } } - /// Handle call ended event - void _onCallEnded(List? args) { - log('πŸ“ž [CALL EVENT] OnHangUpAsync received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Current status: $callStatus', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Call ended by peer - cleaning up', name: 'ChatProvider'); + /// Handle connection failure (ICE connection failed) + void _handleConnectionFailure() { + log('❌ [CALL] Connection failure detected', name: 'ChatProvider'); - if (kDebugMode) { - print('πŸ“ž Call ended by peer'); + final context = navigatorKey.currentContext; + + // Notify user about connection failure + if (context != null && context.mounted) { + CallErrorHandler.showConnectionFailed(context); } + // Send hangup signal to peer + if (chatHubConnection?.state == HubConnectionState.Connected && + sender != null && + currentCall != null) { + chatHubConnection!.invoke( + 'HangUpAsync', + args: [ + sender!.employeeNumber ?? '', + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ).catchError((e) { + log('❌ [CALL] Failed to send hangup after connection failure: $e', name: 'ChatProvider'); + }); + } + + // Clean up call resources _teardownCall(); + + if (kDebugMode) { + print('❌ Call ended due to connection failure'); + } } - /// Handle peer audio toggle event - void _onPeerAudioToggle(List? args) { - log('🎀 [CALL EVENT] OnAudioToggle received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Previous peer mute state: $isPeerMuted', name: 'ChatProvider'); + /// Start timeout for ICE connection establishment (60 seconds - increased for TURN relay) + void _startConnectionTimeout() { + _callTimeoutTimer?.cancel(); - isPeerMuted = !isPeerMuted; - notifyListeners(); - log('πŸ”΅ [CALL EVENT] New peer mute state: $isPeerMuted', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Peer ${isPeerMuted ? "muted" : "unmuted"} their microphone', name: 'ChatProvider'); + _callTimeoutTimer = Timer(const Duration(seconds: 60), () { + log('⏱️ [CALL] Connection timeout - ICE failed to establish within 60s', name: 'ChatProvider'); - if (kDebugMode) { - print('🎀 Peer ${isPeerMuted ? "muted" : "unmuted"}'); + if (callStatus == CallStatus.connecting) { + log('❌ [CALL] Ending call due to connection timeout', name: 'ChatProvider'); + _handleConnectionFailure(); + } + }); + + log('⏱️ [CALL] Connection timeout started (60s - extended for TURN relay)', name: 'ChatProvider'); + } + + /// Start timeout for reconnection (10 seconds) + void _startReconnectionTimeout() { + _callTimeoutTimer?.cancel(); + + _callTimeoutTimer = Timer(const Duration(seconds: 10), () { + log('⏱️ [CALL] Reconnection timeout - connection did not recover', name: 'ChatProvider'); + + if (callStatus != CallStatus.connected && callStatus != CallStatus.idle) { + log('❌ [CALL] Ending call due to reconnection timeout', name: 'ChatProvider'); + _handleConnectionFailure(); + } + }); + + log('⏱️ [CALL] Reconnection timeout started (10s)', name: 'ChatProvider'); + } + + /// Initialize CallKit service + Future _initializeCallKit() async { + if (_callKitInitialized) return; + + try { + log('πŸ“ž [CallKit] Initializing CallKit service...', name: 'ChatProvider'); + + await _callKitService.initialize(); + + // Setup CallKit callbacks + _callKitService.onCallAccepted = _handleCallKitAccepted; + _callKitService.onCallDeclined = _handleCallKitDeclined; + _callKitService.onCallEnded = _handleCallKitEnded; + _callKitService.onCallTimeout = _handleCallKitTimeout; + + _callKitInitialized = true; + log('βœ… [CallKit] Service initialized successfully', name: 'ChatProvider'); + } catch (e, stackTrace) { + log('❌ [CallKit] Error initializing: $e', + name: 'ChatProvider', error: e, stackTrace: stackTrace); } } - /// Handle peer camera toggle event - void _onPeerCameraToggle(List? args) { - log('πŸ“Ή [CALL EVENT] OnCameraToggle received', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Raw args: $args', name: 'ChatProvider'); - log('πŸ”΅ [CALL EVENT] Previous peer camera state: $isPeerCameraOn', name: 'ChatProvider'); + /// Handle CallKit accept event + void _handleCallKitAccepted(String callId) { + log('═══════════════════════════════════════════', name: 'ChatProvider'); + log('βœ… [CallKit] Call accepted via native UI: $callId', name: 'ChatProvider'); + log('πŸ”΅ [CallKit] Current call ID: ${currentCall?.callId}', name: 'ChatProvider'); + log('πŸ”΅ [CallKit] Current call status: $callStatus', name: 'ChatProvider'); + log('═══════════════════════════════════════════', name: 'ChatProvider'); + + // Find the call by ID and accept it + if (currentCall != null && currentCall!.callId == callId) { + log('βœ… [CallKit] Call IDs match - calling acceptCall()', name: 'ChatProvider'); - isPeerCameraOn = !isPeerCameraOn; - notifyListeners(); - log('πŸ”΅ [CALL EVENT] New peer camera state: $isPeerCameraOn', name: 'ChatProvider'); - log('βœ… [CALL EVENT] Peer turned camera ${isPeerCameraOn ? "on" : "off"}', name: 'ChatProvider'); + // Schedule on next frame to ensure we're on main thread + WidgetsBinding.instance.addPostFrameCallback((_) { + log('πŸ”΅ [CallKit] Post-frame callback executing acceptCall()', name: 'ChatProvider'); + acceptCall(); + }); + + // Also call immediately in case we're already on main thread + acceptCall(); + } else { + log('⚠️ [CallKit] Call IDs do NOT match or currentCall is null', name: 'ChatProvider'); + log(' Expected: $callId', name: 'ChatProvider'); + log(' Current: ${currentCall?.callId}', name: 'ChatProvider'); + } + } + + /// Handle CallKit decline event + void _handleCallKitDeclined(String callId) { + log('❌ [CallKit] Call declined via native UI: $callId', name: 'ChatProvider'); + + // Find the call by ID and decline it + if (currentCall != null && currentCall!.callId == callId) { + declineCall('user_declined_native_ui'); + } + } + + /// Handle CallKit ended event + void _handleCallKitEnded(String callId) { + log('πŸ”΄ [CallKit] Call ended via native UI: $callId', name: 'ChatProvider'); + + // End the call + if (currentCall != null && currentCall!.callId == callId) { + hangUp(); + } + } + + /// Handle CallKit timeout event + void _handleCallKitTimeout(String callId) { + log('⏱️ [CallKit] Call timeout: $callId', name: 'ChatProvider'); + + // Handle timeout + if (currentCall != null && currentCall!.callId == callId) { + _teardownCall(); + } } }