import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:supabase_flutter/supabase_flutter.dart'; import '../config/supabase_config.dart'; import '../models/category.dart'; import '../models/profile.dart'; import '../models/profile_privacy_settings.dart'; import '../models/post.dart'; import '../models/comment.dart'; import '../models/tone_analysis.dart' hide ToneAnalysis; import '../models/search_results.dart'; import '../models/notification.dart'; import '../models/beacon.dart'; /// API service for calling sojorn Edge Functions class ApiService { final SupabaseClient _supabase; ApiService(this._supabase); /// Helper to make Edge Function calls /// Trust the Supabase SDK to handle auth, tokens, and headers automatically Future> _callFunction( String functionName, { HttpMethod method = HttpMethod.post, Map? queryParams, Object? body, }) async { final response = await _supabase.functions.invoke( functionName, method: method, queryParameters: queryParams, body: body, ); // Parse response data final data = response.data; if (data is Map) { return data; } if (data is Map) { return Map.from(data); } if (data is String && data.isNotEmpty) { final parsed = jsonDecode(data); if (parsed is Map) { return parsed; } } throw Exception('Unexpected response from $functionName'); } List> _normalizeListResponse(dynamic response) { try { // Supabase Flutter SDK returns data directly, not wrapped in PostgrestResponse if (response == null) return []; // Handle different response types List rawData; if (response is List) { rawData = response; } else if (response is Map) { // If it's a single object, wrap it in a list return [response]; } else { return []; } return rawData .where((item) => item is Map) .map((item) => Map.from(item as Map)) .toList(); } catch (e) { print('_normalizeListResponse error: $e'); return []; } } static const String _postSelect = ''' id, body, author_id, category_id, tone_label, cis_score, status, created_at, edited_at, deleted_at, is_edited, allow_chain, chain_parent_id, image_url, chain_parent:posts ( id, body, created_at, author:profiles!posts_author_id_fkey ( id, handle, display_name ) ), metrics:post_metrics!post_metrics_post_id_fkey ( like_count, save_count, view_count ), author:profiles!posts_author_id_fkey ( id, handle, display_name, trust_state ( user_id, harmony_score, tier, posts_today ) ) '''; // ============================================================================ // CATEGORIES // ============================================================================ /// Fetch all categories Future> getCategories() async { final response = await _supabase .from('categories') .select() .order('name', ascending: true); return (response as List).map((json) => Category.fromJson(json)).toList(); } /// Fetch categories the user has enabled 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(); } /// Check if the current user has a profile Future hasProfile() async { final user = _supabase.auth.currentUser; if (user == null) { return false; } try { final response = await _supabase .from('profiles') .select('id') .eq('id', user.id) .limit(1); return (response as List).isNotEmpty; } catch (e) { // If we get an error (like RLS blocking or JWT issues), assume no profile // This allows the signup flow to work even if RLS is strict print('hasProfile error (treating as false): $e'); return false; } } /// Check if the user has completed category selection Future hasCategorySelection() async { try { final enabledIds = await _getEnabledCategoryIds(); return enabledIds.isNotEmpty; } catch (e) { // If we get an error, assume no category selection print('hasCategorySelection error (treating as false): $e'); return false; } } /// Persist user category settings for all categories Future setUserCategorySettings({ required List categories, required Set enabledCategoryIds, }) async { final user = _supabase.auth.currentUser; if (user == null) { throw Exception('Not authenticated'); } final payload = categories .map( (category) => { 'user_id': user.id, 'category_id': category.id, 'enabled': enabledCategoryIds.contains(category.id), }, ) .toList(); await _supabase.from('user_category_settings').upsert(payload); } Future> _getEnabledCategoryIds() async { try { final response = await _supabase .from('user_category_settings') .select('category_id') .eq('enabled', true); return (response as List) .map((row) => row['category_id'] as String) .toSet(); } catch (e) { // If query fails, return empty set print('_getEnabledCategoryIds error: $e'); return {}; } } // ============================================================================ // USER MANAGEMENT // ============================================================================ /// Create profile (after Supabase Auth signup) Future createProfile({ required String handle, required String displayName, String? bio, }) async { final response = await _callFunction( 'signup', method: 'POST', body: { 'handle': handle, 'display_name': displayName, if (bio != null) 'bio': bio, }, ); if (_isSuccessStatus(response)) { final data = jsonDecode(response.body); return Profile.fromJson(data['profile']); } else { throw Exception(_extractError(response, 'Failed to create profile')); } } /// Get profile by handle Future> getProfile({String? handle}) async { final response = await _callFunction( 'profile', queryParams: handle != null ? {'handle': handle} : null, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return { 'profile': Profile.fromJson(data['profile']), 'stats': ProfileStats.fromJson(data['stats']), 'is_following': data['is_following'] as bool? ?? false, }; } else { throw Exception(_extractError(response, 'Failed to get profile')); } } /// Fetch posts authored by the given profile Future> getProfilePosts({ required String authorId, int limit = 20, int offset = 0, }) async { final safeOffset = offset < 0 ? 0 : offset; final safeLimit = limit.clamp(1, 100).toInt(); final rangeEnd = safeOffset + safeLimit - 1; try { final response = await _supabase .from('posts') .select(''' id, body, author_id, category_id, tone_label, cis_score, status, created_at, edited_at, deleted_at, allow_chain, chain_parent_id, image_url, chain_parent:posts ( id, body, created_at, author:profiles!posts_author_id_fkey ( id, handle, display_name ) ), metrics:post_metrics!post_metrics_post_id_fkey ( like_count, save_count, view_count ), author:profiles!posts_author_id_fkey ( id, handle, display_name, trust_state ( user_id, harmony_score, tier, posts_today ) ) ''') .eq('author_id', authorId) .order('created_at', ascending: false) .range(safeOffset, rangeEnd); final rows = _normalizeListResponse(response); return rows.map(Post.fromJson).toList(); } catch (error) { final message = error is PostgrestException ? error.message : 'Failed to load profile posts'; print('Error fetching profile posts: $error'); throw Exception('Failed to load profile posts: $message'); } } /// Fetch a single post by id Future getPostById(String postId) async { try { final response = await _supabase .from('posts') .select(_postSelect) .eq('id', postId) .single(); // Handle both Map and List response formats final Map data; if (response is Map) { data = response; } else if (response is List && response.isNotEmpty) { data = Map.from(response[0] as Map); } else { data = {}; } return Post.fromJson(data); } catch (error) { final message = error is PostgrestException ? error.message : 'Failed to load post'; throw Exception('Failed to load post: $message'); } } /// Update own profile Future updateProfile({ String? handle, String? displayName, String? bio, String? location, String? website, List? interests, String? avatarUrl, String? coverUrl, }) async { final response = await _callFunction( '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 (response.statusCode == 200) { final data = jsonDecode(response.body); return Profile.fromJson(data['profile']); } else { throw Exception(_extractError(response, 'Failed to update profile')); } } /// Fetch or initialize privacy settings for the current user Future getPrivacySettings() async { final userId = _supabase.auth.currentUser?.id; if (userId == null) { throw Exception('Not authenticated'); } final response = await _supabase .from('profile_privacy_settings') .select() .eq('user_id', userId) .limit(1); final rows = _normalizeListResponse(response); if (rows.isNotEmpty) { return ProfilePrivacySettings.fromJson(rows.first); } final now = DateTime.now().toIso8601String(); final defaults = ProfilePrivacySettings.defaults(userId); final inserted = await _supabase .from('profile_privacy_settings') .insert({ ...defaults.toJson(), 'created_at': now, 'updated_at': now, }) .select() .single(); return ProfilePrivacySettings.fromJson(inserted); } /// Update privacy settings for the current user Future updatePrivacySettings( ProfilePrivacySettings settings, ) async { final userId = _supabase.auth.currentUser?.id; if (userId == null) { throw Exception('Not authenticated'); } final payload = settings.toJson() ..['user_id'] = userId ..['updated_at'] = DateTime.now().toIso8601String(); final response = await _supabase .from('profile_privacy_settings') .upsert(payload) .select() .single(); return ProfilePrivacySettings.fromJson(response); } /// Fetch posts the user has appreciated Future> getAppreciatedPosts({ required String userId, int limit = 20, int offset = 0, }) async { final safeLimit = limit.clamp(1, 100).toInt(); final safeOffset = offset < 0 ? 0 : offset; final rangeEnd = safeOffset + safeLimit - 1; final response = await _supabase .from('post_likes') .select('post:posts ($_postSelect)') .eq('user_id', userId) .order('created_at', ascending: false) .range(safeOffset, rangeEnd); // Handle embedded response format final List rawData = response as List; return rawData .map((row) => row['post'] as Map?) .where((post) => post != null) .map((post) { final payload = Map.from(post!); payload['user_liked'] = true; return Post.fromJson(payload); }) .toList(); } /// Fetch posts the user has saved Future> getSavedPosts({ required String userId, int limit = 20, int offset = 0, }) async { final safeLimit = limit.clamp(1, 100).toInt(); final safeOffset = offset < 0 ? 0 : offset; final rangeEnd = safeOffset + safeLimit - 1; final response = await _supabase .from('post_saves') .select('post:posts ($_postSelect)') .eq('user_id', userId) .order('created_at', ascending: false) .range(safeOffset, rangeEnd); // Handle embedded response format final List rawData = response as List; return rawData .map((row) => row['post'] as Map?) .where((post) => post != null) .map((post) { final payload = Map.from(post!); payload['user_saved'] = true; return Post.fromJson(payload); }) .toList(); } /// Fetch posts by an author that have chain responses Future> getChainedPostsForAuthor({ required String authorId, int limit = 20, int offset = 0, }) async { final safeLimit = limit.clamp(1, 100).toInt(); final safeOffset = offset < 0 ? 0 : offset; final rangeEnd = safeOffset + safeLimit - 1; final response = await _supabase .from('posts') .select('$_postSelect, chain_children:posts!inner(id)') .eq('author_id', authorId) .order('created_at', ascending: false) .range(safeOffset, rangeEnd); final rows = _normalizeListResponse(response); return rows.map(Post.fromJson).toList(); } /// Follow a user Future followUser(String userId) async { final response = await _callFunction( 'follow', method: 'POST', body: {'user_id': userId}, ); if (response.statusCode != 200) { throw Exception( jsonDecode(response.body)['error'] ?? 'Failed to follow user', ); } } /// Unfollow a user Future unfollowUser(String userId) async { final response = await _callFunction( 'follow', method: 'DELETE', body: {'user_id': userId}, ); if (response.statusCode != 200) { throw Exception( jsonDecode(response.body)['error'] ?? 'Failed to unfollow user', ); } } /// Block a user Future blockUser(String userId) async { final response = await _callFunction( 'block', method: 'POST', body: {'user_id': userId}, ); if (response.statusCode != 200) { throw Exception( jsonDecode(response.body)['error'] ?? 'Failed to block user', ); } } /// Unblock a user Future unblockUser(String userId) async { final response = await _callFunction( 'block', method: 'DELETE', body: {'user_id': userId}, ); if (response.statusCode != 200) { throw Exception( jsonDecode(response.body)['error'] ?? 'Failed to unblock user', ); } } // ============================================================================ // CONTENT PUBLISHING // ============================================================================ /// Publish a post /// /// Supports both regular posts and beacons through unified API. /// For beacons, set [isBeacon] to true and provide [beaconType], [lat], and [long]. Future publishPost({ required String categoryId, required String body, String bodyFormat = 'plain', bool allowChain = true, String? chainParentId, String? imageUrl, // Beacon-specific parameters bool isBeacon = false, BeaconType? beaconType, double? lat, double? long, }) async { final response = await _callFunction( 'publish-post', 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, // Beacon fields 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 (_isSuccessStatus(response)) { final data = jsonDecode(response.body); return Post.fromJson(data['post']); } else { throw Exception(_extractError(response, 'Failed to publish post')); } } /// Publish a comment (mutual-follow only) Future publishComment({ required String postId, required String body, }) async { final response = await _callFunction( 'publish-comment', method: 'POST', body: {'post_id': postId, 'body': body}, ); if (_isSuccessStatus(response)) { final data = jsonDecode(response.body); return Comment.fromJson(data['comment']); } else { throw Exception(_extractError(response, 'Failed to publish comment')); } } // ============================================================================ // TONE ANALYSIS // ============================================================================ /// Analyze the tone of content using AI moderation /// /// Uses intent-based detection to allow authentic expression while /// rejecting slurs and hostile attacks. /// /// [text] The text to analyze /// [imageUrl] Optional image URL for multi-modal analysis /// /// Returns a [ToneCheckResult]. If the analysis fails (API error, /// timeout, etc.), returns a default "neutral" result to allow the post. Future checkTone( String text, { String? imageUrl, }) async { try { final response = await _supabase.functions.invoke( 'tone-check', method: HttpMethod.post, body: { 'text': text, if (imageUrl != null) 'imageUrl': imageUrl, }, ); final data = response.data; if (data is Map) { return ToneCheckResult.fromJson(data); } if (data is Map) { return ToneCheckResult.fromJson(Map.from(data)); } if (data is String && data.isNotEmpty) { final parsed = jsonDecode(data); if (parsed is Map) { return ToneCheckResult.fromJson(parsed); } } // Fallback to neutral if response format is invalid return ToneCheckResult( tone: ToneCategory.neutral, cis: 0.8, flags: [], reason: 'Analysis complete', ); } catch (e) { print('Tone analysis error: $e'); // FALLBACK: Allow post with neutral rating when AI is unavailable // This prevents blocking users when OpenAI has issues return ToneCheckResult( tone: ToneCategory.neutral, cis: 0.8, flags: [], reason: 'Content approved (moderation temporarily unavailable)', ); } } // ============================================================================ // ENGAGEMENT // ============================================================================ /// Appreciate a post Future appreciatePost(String postId) async { final response = await _callFunction( 'appreciate', method: 'POST', body: {'post_id': postId}, ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to appreciate post')); } } /// Remove appreciation Future unappreciatePost(String postId) async { final response = await _callFunction( 'appreciate', method: 'DELETE', body: {'post_id': postId}, ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to remove appreciation')); } } /// Save a post Future savePost(String postId) async { final response = await _callFunction( 'save', method: 'POST', body: {'post_id': postId}, ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to save post')); } } /// Unsave a post Future unsavePost(String postId) async { final response = await _callFunction( 'save', method: 'DELETE', body: {'post_id': postId}, ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to unsave post')); } } /// Report content or user Future reportContent({ required String targetType, required String targetId, required String reason, }) async { final response = await _callFunction( 'report', method: 'POST', body: { 'target_type': targetType, 'target_id': targetId, 'reason': reason, }, ); if (!_isSuccessStatus(response)) { throw Exception(_extractError(response, 'Failed to submit report')); } } // ============================================================================ // FEEDS // ============================================================================ /// Get personal feed (chronological from follows) Future> getPersonalFeed({int limit = 50, int offset = 0}) async { final response = await _callFunction( 'feed-personal', method: 'GET', queryParams: {'limit': limit.toString(), 'offset': offset.toString()}, ); if (_isSuccessStatus(response)) { final data = jsonDecode(response.body); return (data['posts'] as List) .map((json) => Post.fromJson(json)) .toList(); } else { throw Exception(_extractError(response, 'Failed to get personal feed')); } } /// Get sojorn feed (algorithmic FYP) Future> getsojornFeed({int limit = 50, int offset = 0}) async { final response = await _callFunction( 'feed-sojorn', method: 'GET', queryParams: {'limit': limit.toString(), 'offset': offset.toString()}, ); if (_isSuccessStatus(response)) { final data = jsonDecode(response.body); return (data['posts'] as List) .map((json) => Post.fromJson(json)) .toList(); } else { throw Exception(_extractError(response, 'Failed to get sojorn feed')); } } /// Get trending posts by category Future> getTrending({ String category = 'general', int limit = 20, }) async { final response = await _callFunction( 'trending', queryParams: {'category': category, 'limit': limit.toString()}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return (data['posts'] as List) .map((json) => Post.fromJson(json)) .toList(); } else { throw Exception(_extractError(response, 'Failed to get trending')); } } // ============================================================================ // POST MANAGEMENT (EDIT/DELETE) // ============================================================================ /// Edit a post (within 2-minute window) /// Requires passing moderation check Future editPost({ required String postId, required String content, }) async { final response = await _callFunction( 'manage-post', method: 'POST', body: { 'action': 'edit', 'post_id': postId, 'content': content, }, ); if (!_isSuccessStatus(response)) { throw Exception(_extractError(response, 'Failed to edit post')); } } /// Soft delete a post Future deletePost(String postId) async { final response = await _callFunction( 'manage-post', method: 'POST', body: { 'action': 'delete', 'post_id': postId, }, ); if (!_isSuccessStatus(response)) { throw Exception(_extractError(response, 'Failed to delete post')); } } // ============================================================================ // CHAIN RESPONSES // ============================================================================ /// Fetch chain responses for a post (chronological) Future> getChainPosts({ required String parentPostId, int limit = 50, }) async { final safeLimit = limit.clamp(1, 100).toInt(); final response = await _supabase .from('posts') .select(''' id, body, author_id, category_id, tone_label, cis_score, status, created_at, edited_at, deleted_at, allow_chain, chain_parent_id, image_url, chain_parent:posts ( id, body, created_at, author:profiles!posts_author_id_fkey ( id, handle, display_name ) ), metrics:post_metrics!post_metrics_post_id_fkey ( like_count, save_count, view_count ), author:profiles!posts_author_id_fkey ( id, handle, display_name, trust_state ( user_id, harmony_score, tier, posts_today ) ) ''') .eq('chain_parent_id', parentPostId) .order('created_at', ascending: true) .limit(safeLimit); final rows = _normalizeListResponse(response); return rows.map(Post.fromJson).toList(); } /// Deactivate user account Future deactivateAccount() async { final response = await _callFunction( 'deactivate-account', method: 'POST', ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to deactivate account')); } } /// Reactivate a deactivated account Future reactivateAccount() async { final response = await _callFunction( 'deactivate-account/reactivate', method: 'POST', ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to reactivate account')); } } /// Request permanent account deletion (30-day waiting period) Future> requestAccountDeletion() async { final response = await _callFunction( 'delete-account', method: 'POST', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data; } else { throw Exception(_extractError(response, 'Failed to request account deletion')); } } /// Cancel pending account deletion request Future cancelAccountDeletion() async { final response = await _callFunction( 'delete-account/cancel', method: 'POST', ); if (response.statusCode != 200) { throw Exception(_extractError(response, 'Failed to cancel account deletion')); } } // ============================================================================ // SEARCH // ============================================================================ /// Search for users and hashtags simultaneously /// Uses the database RPC function for efficient searching Future search(String query) async { if (query.trim().isEmpty) { return SearchResults(users: [], tags: [], posts: []); } try { final response = await _supabase .rpc('search_sojorn', params: {'p_query': query.trim()}); if (response is Map) { return SearchResults.fromJson(response); } else if (response is String && response.isNotEmpty) { final parsed = jsonDecode(response); if (parsed is Map) { return SearchResults.fromJson(parsed); } } // Fallback for empty results return SearchResults(users: [], tags: [], posts: []); } catch (e) { print('Search error: $e'); // Return empty results on error return SearchResults(users: [], tags: [], posts: []); } } // ============================================================================ // NOTIFICATIONS // ============================================================================ /// Get notifications for the current user /// [limit] Maximum number of notifications to fetch (default: 20) /// [offset] Number of notifications to skip for pagination (default: 0) /// [unreadOnly] If true, only fetch unread notifications (default: false) Future> getNotifications({ int limit = 20, int offset = 0, bool unreadOnly = false, }) async { final response = await _callFunction( 'notifications', method: 'GET', queryParams: { 'limit': limit.toString(), 'offset': offset.toString(), if (unreadOnly) 'unread_only': 'true', }, ); if (_isSuccessStatus(response)) { final data = jsonDecode(response.body); if (data is List) { return data.map((json) => AppNotification.fromJson(json)).toList(); } return []; } else { throw Exception(_extractError(response, 'Failed to get notifications')); } } /// Mark specific notifications as read /// [notificationIds] List of notification IDs to mark as read Future markNotificationsAsRead(List notificationIds) async { final response = await _callFunction( 'notifications', method: 'PATCH', body: {'notification_ids': notificationIds}, ); if (!_isSuccessStatus(response)) { throw Exception(_extractError(response, 'Failed to mark notifications as read')); } } /// Mark all notifications as read Future markAllNotificationsAsRead() async { final response = await _callFunction( 'notifications', method: 'PATCH', body: {'mark_all_read': true}, ); if (!_isSuccessStatus(response)) { throw Exception(_extractError(response, 'Failed to mark all notifications as read')); } } /// Get the count of unread notifications Future getUnreadNotificationCount() async { final userId = _supabase.auth.currentUser?.id; if (userId == null) { return 0; } try { final response = await _supabase .rpc('get_unread_notification_count', params: {'p_user_id': userId}); if (response is int) { return response; } return 0; } catch (e) { print('Error getting unread notification count: $e'); return 0; } } }