**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
165 lines
5 KiB
Dart
165 lines
5 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../config/firebase_web_config.dart';
|
|
import '../routes/app_routes.dart';
|
|
import '../services/secure_chat_service.dart';
|
|
import '../screens/secure_chat/secure_chat_screen.dart';
|
|
import 'api_service.dart';
|
|
|
|
class NotificationService {
|
|
NotificationService._internal();
|
|
|
|
static final NotificationService instance = NotificationService._internal();
|
|
|
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
|
|
|
bool _initialized = false;
|
|
String? _currentToken;
|
|
String? _cachedVapidKey;
|
|
|
|
Future<void> init() async {
|
|
if (_initialized) return;
|
|
_initialized = true;
|
|
|
|
try {
|
|
debugPrint('[FCM] Initializing for platform: ${_resolveDeviceType()}');
|
|
|
|
final settings = await _messaging.requestPermission(
|
|
alert: true,
|
|
badge: true,
|
|
sound: true,
|
|
provisional: false,
|
|
);
|
|
|
|
debugPrint('[FCM] Permission status: ${settings.authorizationStatus}');
|
|
|
|
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
|
debugPrint('[FCM] Push notification permission denied');
|
|
return;
|
|
}
|
|
|
|
final vapidKey = kIsWeb ? await _resolveVapidKey() : null;
|
|
if (kIsWeb && (vapidKey == null || vapidKey.isEmpty)) {
|
|
debugPrint('[FCM] Web push is missing FIREBASE_WEB_VAPID_KEY');
|
|
}
|
|
|
|
debugPrint('[FCM] Requesting token...');
|
|
final token = await _messaging.getToken(
|
|
vapidKey: vapidKey,
|
|
);
|
|
|
|
if (token != null) {
|
|
_currentToken = token;
|
|
debugPrint('[FCM] Token registered (${_resolveDeviceType()}): $token');
|
|
await _upsertToken(token);
|
|
} else {
|
|
debugPrint('[FCM] WARNING: Token is null after getToken()');
|
|
}
|
|
|
|
_messaging.onTokenRefresh.listen((newToken) {
|
|
debugPrint('[FCM] Token refreshed: $newToken');
|
|
_currentToken = newToken;
|
|
_upsertToken(newToken);
|
|
});
|
|
|
|
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpen);
|
|
FirebaseMessaging.onMessage.listen((message) {
|
|
debugPrint('[FCM] Foreground message received: ${message.messageId}');
|
|
debugPrint('[FCM] Message data: ${message.data}');
|
|
debugPrint('[FCM] Notification: ${message.notification?.title}');
|
|
});
|
|
|
|
final initialMessage = await _messaging.getInitialMessage();
|
|
if (initialMessage != null) {
|
|
debugPrint('[FCM] App opened from notification: ${initialMessage.messageId}');
|
|
await _handleMessageOpen(initialMessage);
|
|
}
|
|
|
|
debugPrint('[FCM] Initialization complete');
|
|
} catch (e, stackTrace) {
|
|
debugPrint('[FCM] Failed to initialize notifications: $e');
|
|
debugPrint('[FCM] Stack trace: $stackTrace');
|
|
}
|
|
}
|
|
|
|
/// Remove the current device's FCM token (call on logout)
|
|
Future<void> removeToken() async {
|
|
if (_currentToken == null) return;
|
|
|
|
// Migrate this to Go API
|
|
_currentToken = null;
|
|
}
|
|
|
|
Future<void> _upsertToken(String token) async {
|
|
try {
|
|
debugPrint('[FCM] Syncing token with backend...');
|
|
await ApiService.instance.callGoApi(
|
|
'/notifications/device',
|
|
method: 'POST',
|
|
body: {
|
|
'fcm_token': token,
|
|
'platform': _resolveDeviceType()
|
|
}
|
|
);
|
|
debugPrint('[FCM] Token synced with Go Backend successfully');
|
|
} catch (e, stackTrace) {
|
|
debugPrint('[FCM] Sync failed: $e');
|
|
debugPrint('[FCM] Stack trace: $stackTrace');
|
|
}
|
|
}
|
|
|
|
String _resolveDeviceType() {
|
|
if (kIsWeb) return 'web';
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
return 'ios';
|
|
case TargetPlatform.android:
|
|
return 'android';
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.windows:
|
|
return 'desktop';
|
|
}
|
|
}
|
|
|
|
Future<String?> _resolveVapidKey() async {
|
|
if (_cachedVapidKey != null && _cachedVapidKey!.isNotEmpty) {
|
|
return _cachedVapidKey;
|
|
}
|
|
|
|
final envKey = FirebaseWebConfig.vapidKey;
|
|
if (envKey != null && envKey.isNotEmpty) {
|
|
_cachedVapidKey = envKey;
|
|
return envKey;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _handleMessageOpen(RemoteMessage message) async {
|
|
final data = message.data;
|
|
if (data['type'] != 'chat' && data['type'] != 'new_message') return;
|
|
final conversationId = data['conversation_id'];
|
|
if (conversationId == null) return;
|
|
|
|
await _openConversation(conversationId.toString());
|
|
}
|
|
|
|
Future<void> _openConversation(String conversationId) async {
|
|
final conversation =
|
|
await SecureChatService.instance.getConversationById(conversationId);
|
|
if (conversation == null) return;
|
|
|
|
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
|
if (navigator == null) return;
|
|
|
|
navigator.push(
|
|
MaterialPageRoute(
|
|
builder: (_) => SecureChatScreen(conversation: conversation),
|
|
),
|
|
);
|
|
}
|
|
}
|