Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:dynamic_color/dynamic_color.dart';
Expand Down Expand Up @@ -80,8 +81,12 @@ class _HazeBotAdminAppState extends State<HazeBotAdminApp>
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
// App going to background - disconnect WebSocket
debugPrint('πŸ“± App paused/inactive - disconnecting WebSocket');
discordAuthService.wsService.disconnect();
// βœ… FIX: Web should NOT disconnect on inactive (tab switch)
// Only mobile needs disconnect (app minimized)
if (!kIsWeb) {
debugPrint('πŸ“± App paused/inactive - disconnecting WebSocket');
discordAuthService.wsService.disconnect();
}
} else if (state == AppLifecycleState.resumed) {
// App coming to foreground - reconnect if authenticated
if (discordAuthService.isAuthenticated) {
Expand Down
143 changes: 9 additions & 134 deletions lib/screens/admin/ticket_detail_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class TicketDetailDialog extends StatefulWidget {
}

class _TicketDetailDialogState extends State<TicketDetailDialog>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
with SingleTickerProviderStateMixin {
// βœ… REMOVED WidgetsBindingObserver - TicketChatWidget handles WebSocket lifecycle
late TabController _tabController;
List<TicketMessage> _messages = [];
bool _isLoadingMessages = false;
Expand All @@ -35,19 +36,12 @@ class _TicketDetailDialogState extends State<TicketDetailDialog>
Set<String> _seenMessageIds = {};
int _firstNewMessageIndex = -1; // Index of first new message for divider

// Store AuthService reference to avoid accessing context in dispose
AuthService? _authService; // βœ… Nullable to avoid LateInitializationError
String? _currentUserDiscordId; // βœ… For push notification suppression
bool _wasDisconnectedWhilePaused = false; // βœ… Track if we missed messages
// βœ… REMOVED: WebSocket-related variables (now handled by TicketChatWidget)

@override
void initState() {
super.initState();
WidgetsBinding.instance
.addObserver(this); // βœ… Observe app lifecycle changes

// Load current user ID for push notification suppression
_loadCurrentUser();
// βœ… REMOVED: No longer observing app lifecycle - TicketChatWidget handles this

_tabController = TabController(
length: 2,
Expand All @@ -67,127 +61,15 @@ class _TicketDetailDialogState extends State<TicketDetailDialog>
}
});

// βœ… CRITICAL: Load user ID FIRST, then setup WebSocket
_initializeWithUser();

// If opening to chat tab, load messages immediately
if (widget.initialTab == 1) {
_loadMessages();
}
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// βœ… Guard: Check if authService is initialized before accessing
if (_authService == null) return;

// βœ… CRITICAL: Leave ticket room when app goes to background
// This ensures push notifications are re-enabled when user is not actively viewing
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
debugPrint(
'πŸ“± App paused/inactive - leaving ticket room to re-enable push notifications');

// Track if WebSocket is disconnected during pause (we might miss messages)
_wasDisconnectedWhilePaused = !_authService!.wsService.isConnected;

_authService!.wsService.leaveTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else if (state == AppLifecycleState.resumed) {
debugPrint(
'πŸ“± App resumed - rejoining ticket room to suppress push notifications');

// βœ… If WebSocket was disconnected, reload messages from API to catch missed ones
if (_wasDisconnectedWhilePaused) {
debugPrint(
'πŸ“± WebSocket was disconnected during pause - reloading messages');
_loadMessages();
_wasDisconnectedWhilePaused = false;
}

_authService!.wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
}
}

/// Load user ID first, then setup WebSocket with user tracking
Future<void> _initializeWithUser() async {
// CRITICAL: Load user ID BEFORE joining WebSocket room
await _loadCurrentUser();

// Now join WebSocket with user ID for push notification suppression
_setupWebSocketListener();
}

/// Load current user's Discord ID for push notification suppression
Future<void> _loadCurrentUser() async {
try {
final authService = Provider.of<AuthService>(context, listen: false);
final userData = await authService.apiService.getCurrentUser();
_currentUserDiscordId = userData['discord_id']?.toString();
debugPrint('πŸ‘€ Current user Discord ID: $_currentUserDiscordId');
} catch (e) {
debugPrint('⚠️ Could not load current user ID: $e');
}
}

void _setupWebSocketListener() {
_authService = Provider.of<AuthService>(context, listen: false);
final wsService = _authService!.wsService;

// Join ticket room with user ID for push notification suppression
wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId, // βœ… Suppress push while viewing
);

// Listen for new messages
wsService.onTicketUpdate(widget.ticket.ticketId, (data) {
final eventType = data['event_type'] as String?;

if (eventType == 'new_message') {
final messageData = data['data'] as Map<String, dynamic>?;
if (messageData != null) {
_handleNewMessage(messageData);
}
}
});
}

void _handleNewMessage(Map<String, dynamic> messageData) {
if (!mounted) return;

final newMessage = TicketMessage.fromJson(messageData);

// Check if message already exists
if (_messages.any((m) => m.id == newMessage.id)) {
return;
}

final oldCount = _messages.length;

setState(() {
_messages.add(newMessage);
_previousMessageCount = _messages.length;
// Mark where new messages start (if not already set)
if (_firstNewMessageIndex < 0) {
_firstNewMessageIndex = oldCount;
}
});

// Scroll behavior on messages tab
if (_tabController.index == 1) {
if (_firstNewMessageIndex >= 0) {
_scrollToNewMessages();
} else {
_scrollToBottom();
}
}
}
// βœ… REMOVED: All WebSocket lifecycle management
// TicketChatWidget (embedded in Messages tab) now handles all WebSocket operations
// This prevents duplicate joins/leaves and multiple lifecycle observers

void _markAllAsSeen() {
setState(() {
Expand All @@ -197,15 +79,8 @@ class _TicketDetailDialogState extends State<TicketDetailDialog>

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this); // βœ… Remove observer

// Leave WebSocket ticket room with user ID to re-enable push notifications
if (_authService != null) {
_authService!.wsService.leaveTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId, // βœ… Re-enable push when leaving
);
}
// βœ… REMOVED: No longer managing WebSocket lifecycle
// TicketChatWidget handles join/leave operations

_tabController.dispose();
_messageController.dispose();
Expand Down
39 changes: 36 additions & 3 deletions lib/services/websocket_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class WebSocketService {
_socket!.onConnect((_) {
print('βœ… WebSocket connected');
_isConnected = true;
print('βœ… WebSocket connection established');
});

_socket!.onDisconnect((_) {
Expand Down Expand Up @@ -93,6 +94,29 @@ class WebSocketService {
}
}

/// Wait for WebSocket to connect
/// Returns true if connected, false if timeout
Future<bool> waitForConnection({Duration timeout = const Duration(seconds: 5)}) async {
if (isConnected) {
print('βœ… Already connected');
return true;
}

print('⏳ Waiting for WebSocket connection...');
final start = DateTime.now();

while (!isConnected) {
if (DateTime.now().difference(start) > timeout) {
print('❌ WebSocket connection timeout after ${timeout.inSeconds}s');
return false;
}
await Future.delayed(const Duration(milliseconds: 100));
}

print('βœ… WebSocket connection established');
return true;
}

/// Disconnect WebSocket
void disconnect() {
if (_socket != null) {
Expand Down Expand Up @@ -123,14 +147,19 @@ class WebSocketService {
_socket!.dispose();
_socket = null;
_isConnected = false;
_ticketListeners.clear();
// βœ… FIX: Do NOT clear listeners on disconnect!
// Listeners are callbacks registered by widgets and should persist across reconnects
// They will automatically work again when socket reconnects
// _ticketListeners.clear(); // ❌ REMOVED - this caused messages to not arrive after reconnect
}
}

/// Join a ticket room to receive updates
/// [userId] - Discord user ID to suppress push notifications for this user
void joinTicket(String ticketId, {String? userId}) {
if (_socket == null || !_socket!.connected) {
// βœ… FIX: Use _isConnected (same as waitForConnection) instead of _socket!.connected
// This avoids race condition where onConnect fired but socket.io internal state not yet updated
if (_socket == null || !_isConnected) {
print('⚠️ Cannot join ticket: WebSocket not connected');
return;
}
Expand All @@ -156,7 +185,8 @@ class WebSocketService {
/// Leave a ticket room
/// [userId] - Discord user ID to re-enable push notifications for this user
void leaveTicket(String ticketId, {String? userId}) {
if (_socket == null || !_socket!.connected) {
// βœ… FIX: Use _isConnected for consistency
if (_socket == null || !_isConnected) {
return;
}

Expand Down Expand Up @@ -214,13 +244,16 @@ class WebSocketService {

// Notify all listeners for this ticket
if (_ticketListeners.containsKey(ticketId)) {
print('βœ… Found ${_ticketListeners[ticketId]!.length} listener(s) for ticket $ticketId');
for (final listener in _ticketListeners[ticketId]!) {
try {
listener(updateData);
} catch (e) {
print('❌ Error in ticket listener: $e');
}
}
} else {
print('⚠️ No listeners found for ticket $ticketId! Available tickets: ${_ticketListeners.keys.toList()}');
}
} catch (e) {
print('❌ Error handling ticket update: $e');
Expand Down
Loading
Loading