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 00000000..c1ed31f7 Binary files /dev/null and b/assets/audio/outgoing_ringtone.mp3 differ 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(); + } } }