**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.
154 lines
4.2 KiB
Dart
154 lines
4.2 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'auth_service.dart';
|
|
import 'secure_chat_service.dart';
|
|
import 'simple_e2ee_service.dart';
|
|
import 'local_message_store.dart';
|
|
|
|
class SyncManager with WidgetsBindingObserver {
|
|
final SecureChatService _secureChatService;
|
|
final AuthService _authService;
|
|
final SimpleE2EEService _e2ee = SimpleE2EEService();
|
|
final LocalMessageStore _localStore = LocalMessageStore.instance;
|
|
final Duration _resumeThreshold;
|
|
final Duration _syncInterval;
|
|
|
|
Timer? _timer;
|
|
StreamSubscription<AuthState>? _authSub;
|
|
DateTime? _lastSyncAt;
|
|
bool _syncInProgress = false;
|
|
bool _initialized = false;
|
|
bool _hydrating = false;
|
|
|
|
SyncManager({
|
|
required SecureChatService secureChatService,
|
|
required AuthService authService,
|
|
Duration resumeThreshold = const Duration(minutes: 2),
|
|
Duration syncInterval = const Duration(minutes: 5),
|
|
}) : _secureChatService = secureChatService,
|
|
_authService = authService,
|
|
_resumeThreshold = resumeThreshold,
|
|
_syncInterval = syncInterval;
|
|
|
|
void init() {
|
|
if (_initialized) return;
|
|
_initialized = true;
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_authSub = _authService.authStateChanges.listen(_handleAuthChange);
|
|
|
|
if (_authService.isAuthenticated) {
|
|
_startTimer();
|
|
unawaited(ensureHistoryLoaded());
|
|
_triggerSync(reason: 'startup');
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
if (!_initialized) return;
|
|
_initialized = false;
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_authSub?.cancel();
|
|
_authSub = null;
|
|
_stopTimer();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
if (_authService.isAuthenticated) {
|
|
_startTimer();
|
|
_syncIfStale();
|
|
}
|
|
} else if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.detached) {
|
|
_stopTimer();
|
|
}
|
|
}
|
|
|
|
void _handleAuthChange(AuthState state) {
|
|
if (state.event == AuthChangeEvent.signedIn ||
|
|
state.event == AuthChangeEvent.tokenRefreshed) {
|
|
_startTimer();
|
|
unawaited(ensureHistoryLoaded());
|
|
_triggerSync(reason: 'auth');
|
|
} else if (state.event == AuthChangeEvent.signedOut) {
|
|
_lastSyncAt = null;
|
|
_stopTimer();
|
|
}
|
|
}
|
|
|
|
void _startTimer() {
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(_syncInterval, (_) {
|
|
if (_authService.isAuthenticated) {
|
|
_triggerSync(reason: 'timer');
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopTimer() {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
}
|
|
|
|
void _syncIfStale() {
|
|
if (_lastSyncAt == null ||
|
|
DateTime.now().difference(_lastSyncAt!) > _resumeThreshold) {
|
|
_triggerSync(reason: 'resume');
|
|
}
|
|
}
|
|
|
|
Future<void> _triggerSync({required String reason}) async {
|
|
if (!_authService.isAuthenticated || _syncInProgress) return;
|
|
_syncInProgress = true;
|
|
try {
|
|
print('[SYNC] Triggered by: $reason');
|
|
|
|
await _e2ee.initialize();
|
|
if (!_e2ee.isReady) {
|
|
print('[SYNC] Keys not ready, aborting sync.');
|
|
return;
|
|
}
|
|
|
|
await ensureHistoryLoaded();
|
|
|
|
await _secureChatService.syncAllConversations(force: true);
|
|
_lastSyncAt = DateTime.now();
|
|
print('[SYNC] Sync complete.');
|
|
} catch (e) {
|
|
print('[SYNC] Global sync failed ($reason): $e');
|
|
} finally {
|
|
_syncInProgress = false;
|
|
}
|
|
}
|
|
|
|
Future<void> ensureHistoryLoaded() async {
|
|
if (!_authService.isAuthenticated || _hydrating) return;
|
|
_hydrating = true;
|
|
try {
|
|
await _e2ee.initialize();
|
|
if (!_e2ee.isReady) {
|
|
print('[SYNC] Identity not ready; skipping hydration.');
|
|
return;
|
|
}
|
|
|
|
final conversations = await _secureChatService.getConversations();
|
|
for (final conv in conversations) {
|
|
final isEmpty =
|
|
(await _localStore.getMessageIdsForConversation(conv.id)).isEmpty;
|
|
if (isEmpty) {
|
|
print('[SYNC] Hydrating empty conversation: ${conv.id}');
|
|
await _secureChatService.fetchAndDecryptHistory(conv.id, limit: 50);
|
|
}
|
|
await _secureChatService.startLiveListener(conv.id);
|
|
}
|
|
} catch (e) {
|
|
print('[SYNC] History load failed: $e');
|
|
} finally {
|
|
_hydrating = false;
|
|
}
|
|
}
|
|
}
|