sojorn/sojorn_app/lib/services/auth_service.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

361 lines
11 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/api_config.dart';
import '../models/auth_user.dart' as model;
enum AuthChangeEvent { signedIn, signedOut, tokenRefreshed }
class AuthState {
final AuthChangeEvent event;
final Session? session;
const AuthState(this.event, this.session);
}
class Session {
final String accessToken;
final String tokenType;
final User user;
const Session({
required this.accessToken,
required this.tokenType,
required this.user,
});
}
class User {
final String id;
final String? email;
final Map<String, dynamic> userMetadata;
final String? role;
final DateTime? createdAt;
final DateTime? updatedAt;
const User({
required this.id,
this.email,
this.userMetadata = const {},
this.role,
this.createdAt,
this.updatedAt,
});
}
class AuthException implements Exception {
final String message;
AuthException(this.message);
@override
String toString() => message;
}
/// Authentication service for sojorn
/// Handles sign up, sign in, sign out, and auth state
class AuthService {
// Singleton pattern for easy access
static AuthService? _instance;
static AuthService get instance => _instance ??= AuthService._internal();
factory AuthService() {
_instance ??= AuthService._internal();
return _instance!;
}
final _storage = const FlutterSecureStorage();
String? _accessToken;
String? _temporaryToken;
model.AuthUser? _localUser;
bool _initialized = false;
final _authEventController = StreamController<AuthState>.broadcast();
AuthService._internal() {
_init();
}
Future<void> _init() async {
if (_initialized) return;
_accessToken = await _storage.read(key: 'access_token');
final refreshToken = await _storage.read(key: 'refresh_token');
// Also load legacy/temporary token just in case
_temporaryToken = await _storage.read(key: 'go_auth_token');
final userJson = await _storage.read(key: 'go_auth_user');
if (userJson != null) {
try {
_localUser = model.AuthUser.fromJson(jsonDecode(userJson));
} catch (_) {}
}
if (_accessToken != null && refreshToken != null) {
// Optimistic check: decode JWT to see if expired.
if (_isTokenExpired(_accessToken!)) {
print('[AuthService] Token expired at init, attempting refresh...');
await refreshSession();
}
} else if (refreshToken != null) {
// Have refresh but no access? Try refresh
await refreshSession();
}
_initialized = true;
if (isAuthenticated) {
_notifyGoAuthChange();
}
}
bool _isTokenExpired(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) {
return true;
}
final payload = json.decode(utf8.decode(base64Url.decode(base64.normalize(parts[1]))));
if (payload is Map<String, dynamic> && payload.containsKey('exp')) {
final exp = DateTime.fromMillisecondsSinceEpoch(payload['exp'] * 1000);
return DateTime.now().isAfter(exp);
}
} catch (e) {
// If we can't parse it, assume it's invalid
return true;
}
return false; // Default to assumed valid if no exp
}
void _notifyGoAuthChange() {
// Create a synthetic AuthState for the Go token
// We treat this as a signedIn event for the app
final event = AuthState(
AuthChangeEvent.signedIn,
Session(
accessToken: _accessToken ?? '',
tokenType: 'bearer',
user: currentUser!,
),
);
_authEventController.add(event);
}
/// Ensure service is initialized before use
Future<void> ensureInitialized() async {
if (!_initialized) await _init();
}
/// Refresh Logic (The Engine)
Future<bool> refreshSession() async {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) return false;
try {
print('[AuthService] Refreshing session...');
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}/auth/refresh'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refresh_token': refreshToken}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// data should contain access_token and refresh_token
// Or if using the structure returned by handler: { "access_token": "...", "refresh_token": "..." }
await _saveTokens(data['access_token'], data['refresh_token']);
print('[AuthService] Session refreshed successfully');
return true;
} else {
print('[AuthService] Refresh failed: ${response.statusCode}');
await signOut(); // Refresh failed (revoked/expired), force logout
return false;
}
} catch (e) {
print('[AuthService] Refresh error: $e');
return false;
}
}
Future<void> _saveTokens(String access, String refresh) async {
_accessToken = access;
await _storage.write(key: 'access_token', value: access);
await _storage.write(key: 'refresh_token', value: refresh);
// Legacy support
await _storage.write(key: 'go_auth_token', value: access);
_temporaryToken = access;
}
/// Get current user (Wraps locaUser as Supabase User if needed)
User? get currentUser {
if (_localUser != null) {
return User(
id: _localUser!.id,
email: _localUser!.email,
createdAt: _localUser!.createdAt,
updatedAt: _localUser!.updatedAt,
);
}
return null;
}
/// Get current session
Session? get currentSession =>
_accessToken != null && currentUser != null
? Session(
accessToken: _accessToken!,
tokenType: 'bearer',
user: currentUser!,
)
: null;
/// Check if user is authenticated
bool get isAuthenticated => accessToken != null;
/// Get auth state stream
Stream<AuthState> get authStateChanges => _authEventController.stream;
/// Sign up with email and password
@Deprecated('Use registerWithGoBackend')
Future<void> signUpWithEmail({
required String email,
required String password,
}) async {
// No-op
}
/// Sign in with Go Backend (Migration)
Future<Map<String, dynamic>> signInWithGoBackend({
required String email,
required String password,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
print('[AuthService] POST $uri');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
print('[AuthService] Response: ${response.statusCode}');
final data = jsonDecode(response.body);
if (response.statusCode == 200) {
final accessToken = data['token'] ?? data['access_token'];
final refreshToken = data['refresh_token'];
if (accessToken == null || refreshToken == null) {
print('[AuthService] Login response missing tokens: $data');
throw AuthException('Invalid response from server: missing tokens');
}
await _saveTokens(accessToken as String, refreshToken as String);
if (data['user'] != null) {
final userJson = data['user'];
try {
_localUser = model.AuthUser.fromJson(userJson);
await _storage.write(key: 'go_auth_user', value: jsonEncode(userJson));
} catch (e) {
print('[AuthService] Failed to parse user data: $e. Data: $userJson');
}
}
// Handle profile data specifically for onboarding check
if (data['profile'] != null) {
final profileJson = data['profile'];
// Ideally store this in a ProfileService or emit it
// For now, we can rely on the app asking ApiService for profile
await _storage.write(key: 'go_auth_profile_onboarding', value: profileJson['has_completed_onboarding'].toString());
}
_notifyGoAuthChange();
return data;
} else {
throw AuthException(
'Login failed: ${response.statusCode} - ${response.body}',
);
}
} catch (e) {
print('[AuthService] Sign-in exception: $e');
if (e is AuthException) rethrow;
throw AuthException('Connection failed: $e');
}
}
/// Register with Go Backend (Migration)
Future<Map<String, dynamic>> registerWithGoBackend({
required String email,
required String password,
required String handle,
required String displayName,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
print('[AuthService] POST $uri');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': email,
'password': password,
'handle': handle,
'display_name': displayName,
}),
);
print('[AuthService] Response: ${response.statusCode}');
final data = jsonDecode(response.body);
if (response.statusCode == 201) {
// Success: Registration created, verification required.
// We do NOT have tokens here anymore.
// The backend now returns: {"message": "Registration successful...", "user_id": "..."}
return data;
} else {
throw AuthException(
'Registration failed: ${response.statusCode} - ${response.body}',
);
}
} catch (e) {
print('[AuthService] Registration exception: $e');
if (e is AuthException) rethrow;
throw AuthException('Connection failed: $e');
}
}
/// Sign out
Future<void> signOut() async {
_temporaryToken = null;
_accessToken = null;
_localUser = null;
// Clear auth tokens but preserve E2EE keys
await _storage.delete(key: 'access_token');
await _storage.delete(key: 'refresh_token');
await _storage.delete(key: 'go_auth_token');
await _storage.delete(key: 'go_auth_user');
_authEventController.add(const AuthState(AuthChangeEvent.signedOut, null));
}
/// Get current access token
String? get accessToken => _accessToken ?? _temporaryToken ?? currentSession?.accessToken;
/// Send password reset email
Future<void> resetPassword(String email) async {
// Migrate to Go API
}
/// Update password
Future<void> updatePassword(String newPassword) async {
// Migrate to Go API
}
Future<void> markOnboardingCompleteLocally() async {
await _storage.write(key: 'go_auth_profile_onboarding', value: 'true');
}
Future<bool> isOnboardingComplete() async {
final val = await _storage.read(key: 'go_auth_profile_onboarding');
return val == 'true';
}
}