import 'dart:async'; import 'dart:convert'; import '../models/category.dart'; import '../models/profile.dart'; import '../models/follow_request.dart'; import '../models/profile_privacy_settings.dart'; import '../models/post.dart'; import '../models/user_settings.dart'; import '../models/comment.dart'; import '../models/notification.dart'; import '../models/beacon.dart'; import '../config/api_config.dart'; import '../services/auth_service.dart'; import '../models/search_results.dart'; import '../models/tone_analysis.dart'; import 'package:http/http.dart' as http; /// ApiService - Single source of truth for all backend communication. /// Migration: Supabase direct reads are being replaced byGo API calls. class ApiService { final AuthService _authService; final http.Client _httpClient = http.Client(); ApiService(this._authService); // Singleton pattern helper if needed, but usually passed via DI/Riverpod static ApiService? _instance; static ApiService get instance => _instance ??= ApiService(AuthService.instance); /// Generic function caller for Edge Functions. Handles response parsing /// and normalization across different response formats. Future> _callFunction( String functionName, { String method = 'POST', Map? queryParams, Object? body, }) async { // Proxy through Go API to avoid CORS issues and Mixed Content return await _callGoApi( '/functions/$functionName', method: method.toUpperCase(), queryParams: queryParams?.map((k, v) => MapEntry(k, v.toString())), body: body, ); } /// Generic function caller for the new Go API on the VPS. /// Includes Retry-on-401 logic (Session Manager) Future> callGoApi(String path, {String method = 'POST', Map? body, Map? queryParams}) async { return _callGoApi(path, method: method, body: body, queryParams: queryParams); } Future> _callGoApi( String path, { String method = 'POST', Map? queryParams, Object? body, }) async { try { var uri = Uri.parse('${ApiConfig.baseUrl}$path') .replace(queryParameters: queryParams); var headers = await _authHeaders(); headers['Content-Type'] = 'application/json'; http.Response response = await _performRequest(method, uri, headers, body); // INTERCEPTOR: Handle 401 if (response.statusCode == 401) { print('[API] 401 Unauthorized at $path. Attempting refresh...'); final refreshed = await _authService.refreshSession(); if (refreshed) { // Update token header and RETRY headers = await _authHeaders(); headers['Content-Type'] = 'application/json'; print('[API] Retrying request to $path with new token...'); response = await _performRequest(method, uri, headers, body); } else { // Refresh failed, assume session died. throw Exception('Session Expired'); } } if (response.statusCode >= 400) { throw Exception( 'Go API error (${response.statusCode}): ${response.body}'); } if (response.body.isEmpty) return {}; final data = jsonDecode(response.body); if (data is Map) return data; return {'data': data}; } catch (e) { print('Go API call to $path failed: $e'); rethrow; } } Future _performRequest( String method, Uri uri, Map headers, Object? body) async { switch (method.toUpperCase()) { case 'GET': return await _httpClient.get(uri, headers: headers); case 'PATCH': return await _httpClient.patch(uri, headers: headers, body: jsonEncode(body)); case 'DELETE': return await _httpClient.delete(uri, headers: headers, body: jsonEncode(body)); default: return await _httpClient.post(uri, headers: headers, body: jsonEncode(body)); } } Future> _authHeaders() async { // Ensure AuthService has loaded tokens from storage await _authService.ensureInitialized(); final token = _authService.accessToken; if (token == null || token.isEmpty) { return {}; } return { 'Authorization': 'Bearer $token', }; } List> _normalizeListResponse(dynamic response) { if (response == null) return []; if (response is List) { return response .whereType>() .map((item) => Map.from(item)) .toList(); } if (response is Map) return [response]; return []; } Future resendVerificationEmail(String email) async { await _callGoApi('/auth/resend-verification', method: 'POST', body: {'email': email}); } // ========================================================================= // Category & Onboarding // ========================================================================= Future> getCategories() async { final data = await _callGoApi('/categories', method: 'GET'); return (data['categories'] as List) .map((json) => Category.fromJson(json)) .toList(); } Future> getEnabledCategories() async { final categories = await getCategories(); final enabledIds = await _getEnabledCategoryIds(); if (enabledIds.isEmpty) { return categories.where((category) => !category.defaultOff).toList(); } return categories .where((category) => enabledIds.contains(category.id)) .toList(); } Future hasProfile() async { final user = _authService.currentUser; if (user == null) return false; final data = await _callGoApi('/profile', method: 'GET'); return data['profile'] != null; } Future hasCategorySelection() async { try { final enabledIds = await _getEnabledCategoryIds(); return enabledIds.isNotEmpty; } catch (e) { return false; } } Future setUserCategorySettings({ required List categories, required Set enabledCategoryIds, }) async { final settings = categories .map((category) => { 'category_id': category.id, 'enabled': enabledCategoryIds.contains(category.id), }) .toList(); await _callGoApi( '/categories/settings', method: 'POST', body: {'settings': settings}, ); } Future completeOnboarding() async { await _callGoApi('/complete-onboarding', method: 'POST'); // Also update local storage so app knows immediately (AuthService) await _authService.markOnboardingCompleteLocally(); } Future> _getEnabledCategoryIds() async { final data = await _callGoApi('/categories/settings', method: 'GET'); final settings = data['settings'] as List? ?? []; return settings .where((s) => s['enabled'] == true) .map((s) => s['category_id'] as String) .toSet(); } // ========================================================================= // Profile & Auth // ========================================================================= Future createProfile({ required String handle, required String displayName, String? bio, }) async { // Legacy support: still calls generic 'signup' but via auth flow in AuthService usually. // Making this use the endpoint just in case called directly. final data = await _callGoApi( '/auth/signup', // Note: auth routes are usually public, but this helper might assume auth header. // Adjust based on backend. If this requires token, it's fine. // A 'create profile' usually happens after 'auth register'. // If this is the 'onboarding' step for a user who exists but has no profile: method: 'POST', body: { 'handle': handle, 'display_name': displayName, if (bio != null) 'bio': bio, }, ); return Profile.fromJson(data['profile']); } Future> getProfile({String? handle}) async { final data = await _callGoApi( '/profile', method: 'GET', queryParams: handle != null ? {'handle': handle} : null, ); return { 'profile': Profile.fromJson(data['profile'] as Map), 'stats': ProfileStats.fromJson(data['stats'] as Map?), 'is_following': data['is_following'] as bool? ?? false, 'is_followed_by': data['is_followed_by'] as bool? ?? false, 'is_friend': data['is_friend'] as bool? ?? false, 'follow_status': data['follow_status'] as String?, 'is_private': data['is_private'] as bool? ?? false, }; } Future> getProfileById(String userId) async { final data = await _callGoApi( '/profiles/$userId', method: 'GET', ); return { 'profile': Profile.fromJson(data['profile'] as Map), 'stats': ProfileStats.fromJson(data['stats'] as Map?), 'is_following': data['is_following'] as bool? ?? false, 'is_followed_by': data['is_followed_by'] as bool? ?? false, 'is_friend': data['is_friend'] as bool? ?? false, 'follow_status': data['follow_status'] as String?, 'is_private': data['is_private'] as bool? ?? false, }; } Future updateProfile({ String? handle, String? displayName, String? bio, String? location, String? website, List? interests, String? avatarUrl, String? coverUrl, String? identityKey, int? registrationId, String? encryptedPrivateKey, }) async { final data = await _callGoApi( '/profile', method: 'PATCH', body: { if (handle != null) 'handle': handle, if (displayName != null) 'display_name': displayName, if (bio != null) 'bio': bio, if (location != null) 'location': location, if (website != null) 'website': website, if (interests != null) 'interests': interests, if (avatarUrl != null) 'avatar_url': avatarUrl, if (coverUrl != null) 'cover_url': coverUrl, if (identityKey != null) 'identity_key': identityKey, if (registrationId != null) 'registration_id': registrationId, if (encryptedPrivateKey != null) 'encrypted_private_key': encryptedPrivateKey, }, ); return Profile.fromJson(data['profile']); } Future getPrivacySettings() async { try { final data = await _callGoApi('/settings/privacy', method: 'GET'); return ProfilePrivacySettings.fromJson(data); } catch (_) { // Fallback defaults final userId = _authService.currentUser?.id ?? ''; return ProfilePrivacySettings.defaults(userId); } } Future updatePrivacySettings( ProfilePrivacySettings settings, ) async { final data = await _callGoApi( '/settings/privacy', method: 'PATCH', body: settings.toJson(), ); return ProfilePrivacySettings.fromJson(data); } Future getUserSettings() async { try { final data = await _callGoApi('/settings/user', method: 'GET'); // If data is empty or assumes defaults return UserSettings.fromJson(data); } catch (_) { // Fallback final userId = _authService.currentUser?.id ?? ''; return UserSettings(userId: userId, defaultPostTtl: null); } } Future updateUserSettings(UserSettings settings) async { final data = await _callGoApi( '/settings/user', method: 'PATCH', body: settings.toJson(), ); return UserSettings.fromJson(data); } // ========================================================================= // Posts & Feed // ========================================================================= Future> getProfilePosts({ required String authorId, int limit = 20, int offset = 0, }) async { final data = await _callGoApi( '/users/$authorId/posts', method: 'GET', queryParams: { 'limit': limit.toString(), 'offset': offset.toString(), }, ); final posts = data['posts']; if (posts is List) { return posts .whereType>() .map((json) => Post.fromJson(json)) .toList(); } return []; } Future getPostById(String postId) async { final data = await _callGoApi( '/posts/$postId', method: 'GET', ); return Post.fromJson(data['post']); } Future> getAppreciatedPosts({ required String userId, int limit = 20, int offset = 0, }) async { final data = await _callGoApi( '/users/me/liked', method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'}, ); final posts = data['posts'] as List? ?? []; return posts.map((p) => Post.fromJson(p)).toList(); } Future> getSavedPosts({ required String userId, int limit = 20, int offset = 0, }) async { final data = await _callGoApi( '/users/me/saved', method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'}, ); final posts = data['posts'] as List? ?? []; return posts.map((p) => Post.fromJson(p)).toList(); } Future> getChainedPostsForAuthor({ required String authorId, int limit = 20, int offset = 0, }) async { return []; // Go API doesn't have a direct equivalent for 'get chained posts' yet, or use /feed?author_id=...&chained=true } Future> getChainPosts({ required String parentPostId, int limit = 50, }) async { final data = await _callGoApi( '/posts/$parentPostId/chain', method: 'GET', ); final posts = data['posts'] as List? ?? []; return posts.map((p) => Post.fromJson(p)).toList(); } /// Get complete conversation thread with parent-child relationships /// Used for threaded conversations (Reddit-style) Future> getPostChain(String rootPostId) async { final data = await _callGoApi( '/posts/$rootPostId/thread', method: 'GET', ); final posts = data['posts'] as List? ?? []; return posts.map((p) => Post.fromJson(p)).toList(); } /// Get Focus-Context data for the new interactive block system /// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only) Future getPostFocusContext(String postId) async { final data = await _callGoApi( '/posts/$postId/focus-context', method: 'GET', ); return FocusContext.fromJson(data); } // ========================================================================= // Publishing - Unified Post/Beacon Flow // ========================================================================= Future publishPost({ String? categoryId, required String body, String bodyFormat = 'plain', bool allowChain = true, String? chainParentId, String? imageUrl, String? videoUrl, String? thumbnailUrl, int? durationMs, int? ttlHours, bool isBeacon = false, BeaconType? beaconType, double? lat, double? long, bool userWarned = false, }) async { final data = await _callGoApi( '/posts', method: 'POST', body: { 'category_id': categoryId, 'body': body, 'body_format': bodyFormat, 'allow_chain': allowChain, if (chainParentId != null) 'chain_parent_id': chainParentId, if (imageUrl != null) 'image_url': imageUrl, if (videoUrl != null) 'video_url': videoUrl, if (thumbnailUrl != null) 'thumbnail_url': thumbnailUrl, if (durationMs != null) 'duration_ms': durationMs, if (ttlHours != null) 'ttl_hours': ttlHours, if (isBeacon) 'is_beacon': true, if (beaconType != null) 'beacon_type': beaconType.value, if (lat != null) 'beacon_lat': lat, if (long != null) 'beacon_long': long, if (userWarned) 'user_warned': true, }, ); return Post.fromJson(data['post']); } Future publishComment({ required String postId, required String body, }) async { // Backward-compatible: create a chained post so threads render immediately. final post = await publishPost( body: body, chainParentId: postId, allowChain: true, ); return Comment( id: post.id, postId: postId, authorId: post.authorId, body: post.body, status: CommentStatus.active, createdAt: post.createdAt, updatedAt: post.editedAt, author: post.author, voteCount: null, ); } Future editPost({ required String postId, required String content, }) async { await _callGoApi( '/posts/$postId', method: 'PATCH', body: {'body': content}, ); } Future deletePost(String postId) async { await _callGoApi( '/posts/$postId', method: 'DELETE', ); } Future updatePostVisibility({ required String postId, required String visibility, }) async { await _callGoApi( '/posts/$postId/visibility', method: 'PATCH', body: {'visibility': visibility}, ); } Future updateAllPostVisibility(String visibility) async { // Legacy function proxy for bulk update await _callFunction( 'manage-post', method: 'POST', body: { 'action': 'bulk_update_privacy', 'visibility': visibility, }, ); } Future pinPost(String postId) async { await _callGoApi( '/posts/$postId/pin', method: 'POST', body: {'pinned': true}, ); } Future unpinPost(String postId) async { await _callGoApi( '/posts/$postId/pin', method: 'POST', body: {'pinned': false}, // Assuming logic handles boolean toggles ); } // ========================================================================= // Beacons // ========================================================================= Future> fetchNearbyBeacons({ required double lat, required double long, int radius = 16000, }) async { try { final data = await _callGoApi( '/beacons/nearby', method: 'GET', queryParams: { 'lat': lat.toString(), 'long': long.toString(), 'radius': radius.toString(), }, ); return (data['beacons'] as List) .map((json) => Post.fromJson(json)) .toList(); } catch (e) { return []; } } // Beacon voting - migrated to Go API Future vouchBeacon(String beaconId) async { await _callGoApi( '/beacons/$beaconId/vouch', method: 'POST', ); } Future reportBeacon(String beaconId) async { await _callGoApi( '/beacons/$beaconId/report', method: 'POST', ); } Future removeBeaconVote(String beaconId) async { await _callGoApi( '/beacons/$beaconId/vouch', method: 'DELETE', ); } // ========================================================================= // Social Actions // ========================================================================= Future followUser(String userId) async { final data = await _callGoApi( '/users/$userId/follow', method: 'POST', ); // Prefer explicit status, fallback to message if legacy return (data['status'] as String?) ?? (data['message'] as String?); } Future unfollowUser(String userId) async { await _callGoApi( '/users/$userId/follow', method: 'DELETE', ); } Future> getFollowRequests() async { final data = await _callGoApi('/users/requests'); final requests = data['requests'] as List? ?? []; return requests.map((e) => FollowRequest.fromJson(e)).toList(); } Future acceptFollowRequest(String requesterId) async { await _callGoApi( '/users/$requesterId/accept', method: 'POST', ); } Future rejectFollowRequest(String requesterId) async { await _callGoApi( '/users/$requesterId/reject', method: 'DELETE', ); } Future blockUser(String userId) async { // Migrate to Go API } Future unblockUser(String userId) async { // Migrate to Go API } Future appreciatePost(String postId) async { await _callGoApi( '/posts/$postId/like', method: 'POST', ); } Future unappreciatePost(String postId) async { await _callGoApi( '/posts/$postId/like', method: 'DELETE', ); } // ========================================================================= // Chat // ========================================================================= Future> getConversations() async { try { final data = await _callGoApi('/conversations', method: 'GET'); return data['conversations'] as List? ?? []; } catch (e) { // Fallback or empty if API not yet ready return []; } } Future> getConversationById( String conversationId) async { return {}; } Future getOrCreateConversation(String otherUserId) async { final data = await _callGoApi('/conversation', method: 'GET', queryParams: {'other_user_id': otherUserId}); return data['conversation_id'] as String; } Future> sendEncryptedMessage({ required String conversationId, required String ciphertext, // Go expects string (base64) String? receiverId, String? iv, String? keyVersion, String? messageHeader, int messageType = 1, }) async { final data = await _callGoApi('/messages', method: 'POST', body: { 'conversation_id': conversationId, if (receiverId != null) 'receiver_id': receiverId, 'ciphertext': ciphertext, if (iv != null) 'iv': iv, if (keyVersion != null) 'key_version': keyVersion, if (messageHeader != null) 'message_header': messageHeader, 'message_type': messageType }); return data; } Future> getConversationMessages(String conversationId, {int limit = 50, int offset = 0}) async { final data = await _callGoApi('/conversations/$conversationId/messages', method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'}); return data['messages'] as List? ?? []; } Future> getMutualFollows() async { final data = await _callGoApi('/mutual-follows', method: 'GET'); return data['profiles'] as List? ?? []; } Future deleteConversation(String conversationId) async { try { await _callGoApi('/conversations/$conversationId', method: 'DELETE'); return true; } catch (e) { print('[API] Failed to delete conversation: $e'); return false; } } Future deleteMessage(String messageId) async { try { await _callGoApi('/messages/$messageId', method: 'DELETE'); return true; } catch (e) { print('[API] Failed to delete message: $e'); return false; } } // ========================================================================= // E2EE / Keys (Missing Methods) // ========================================================================= Future> getKeyBundle(String userId) async { final data = await callGoApi('/keys/$userId', method: 'GET'); print('[API] Raw Key Bundle for $userId: ${jsonEncode(data)}'); // Go returns nested structure. We normalize to flat keys here. if (data.containsKey('identity_key') && data['identity_key'] is Map) { final identityKey = data['identity_key'] as Map; final signedPrekey = data['signed_prekey'] as Map?; final oneTimePrekey = data['one_time_prekey'] as Map?; return { 'identity_key_public': identityKey['public_key'], 'signed_prekey_public': signedPrekey?['public_key'], 'signed_prekey_id': signedPrekey?['key_id'], 'signed_prekey_signature': signedPrekey?['signature'], 'registration_id': identityKey['key_id'], 'one_time_prekey': oneTimePrekey?['public_key'], 'one_time_prekey_id': oneTimePrekey?['key_id'], }; } return data; } Future publishKeys({ required String identityKeyPublic, required int registrationId, String? preKey, String? signedPrekeyPublic, int? signedPrekeyId, String? signedPrekeySignature, List? oneTimePrekeys, String? identityKey, String? signature, String? signedPreKey, }) async { final actualIdentityKey = identityKey ?? identityKeyPublic; final actualSignature = signature ?? signedPrekeySignature ?? ''; final actualSignedPreKey = signedPreKey ?? signedPrekeyPublic ?? ''; await callGoApi('/keys', method: 'POST', body: { 'identity_key_public': actualIdentityKey, 'signed_prekey_public': actualSignedPreKey, 'signed_prekey_id': signedPrekeyId ?? 1, 'signed_prekey_signature': actualSignature, 'one_time_prekeys': oneTimePrekeys ?? [], 'registration_id': registrationId, }); } // ========================================================================= // Media / Search / Analysis (Missing Methods) // ========================================================================= Future getSignedMediaUrl(String path) async { // Migrate to Go API / Nginx Signed URLs return '${ApiConfig.baseUrl}/media/signed?path=$path'; // Placeholder } Future> toggleReaction(String postId, String emoji) async { final data = await callGoApi( '/posts/$postId/reactions/toggle', method: 'POST', body: {'emoji': emoji}, ); if (data is Map) { return data; } return {}; } Future search(String query) async { try { final data = await callGoApi( '/search', method: 'GET', queryParams: {'q': query}, ); return SearchResults.fromJson(data); } catch (_) { // Return empty results on error return SearchResults(users: [], tags: [], posts: []); } } Future checkTone(String text, {String? imageUrl}) async { try { final data = await callGoApi( '/analysis/tone', method: 'POST', body: { 'text': text, if (imageUrl != null) 'image_url': imageUrl, }, ); return ToneCheckResult.fromJson(data); } catch (_) { // Fallback: allow if analysis fails return ToneCheckResult( flagged: false, category: null, flags: [], reason: 'Analysis unavailable'); } } // ========================================================================= // Notifications & Feed (Missing Methods) // ========================================================================= Future> getPersonalFeed({int limit = 20, int offset = 0}) async { final data = await callGoApi( '/feed', method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'}, ); final posts = data['posts'] as List? ?? []; return posts.map((p) => Post.fromJson(p)).toList(); } Future> getsojornFeed({int limit = 20, int offset = 0}) async { return getPersonalFeed(limit: limit, offset: offset); } Future> getNotifications({ int limit = 20, int offset = 0, bool includeArchived = false, }) async { final data = await callGoApi( '/notifications', method: 'GET', queryParams: { 'limit': '$limit', 'offset': '$offset', 'include_archived': '$includeArchived', }, ); final list = data['notifications'] as List? ?? []; return list .map((n) => AppNotification.fromJson(n as Map)) .toList(); } Future markNotificationsAsRead(List ids) async { await callGoApi( '/notifications/read', method: 'POST', body: {'ids': ids}, ); } Future archiveNotifications(List ids) async { await callGoApi( '/notifications/archive', method: 'POST', body: {'ids': ids}, ); } Future archiveAllNotifications() async { await callGoApi( '/notifications/archive-all', method: 'POST', ); } // ========================================================================= // Post Actions (Missing Methods) // ========================================================================= Future savePost(String postId) async { await callGoApi( '/posts/$postId/save', method: 'POST', ); } Future unsavePost(String postId) async { await callGoApi( '/posts/$postId/save', method: 'DELETE', ); } }