**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.
361 lines
11 KiB
Dart
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';
|
|
}
|
|
}
|