sojorn/sojorn_app/lib/services/sync_manager.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**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.
2026-01-30 07:40:19 -06:00

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;
}
}
}