- Replace NULLIF with CASE WHEN for proper UUID casting - Fix missing ::uuid casting in WHERE clauses - Resolve 'operator does not exist: uuid = text' errors - Focus on post_repository.go, notification_repository.go, and category_repository.go
976 lines
29 KiB
Dart
976 lines
29 KiB
Dart
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<Map<String, dynamic>> _callFunction(
|
|
String functionName, {
|
|
String method = 'POST',
|
|
Map<String, dynamic>? 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<Map<String, dynamic>> callGoApi(String path,
|
|
{String method = 'POST',
|
|
Map<String, dynamic>? body,
|
|
Map<String, String>? queryParams}) async {
|
|
return _callGoApi(path,
|
|
method: method, body: body, queryParams: queryParams);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _callGoApi(
|
|
String path, {
|
|
String method = 'POST',
|
|
Map<String, String>? 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<String, dynamic>) return data;
|
|
return {'data': data};
|
|
} catch (e) {
|
|
print('Go API call to $path failed: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<http.Response> _performRequest(
|
|
String method, Uri uri, Map<String, String> 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<Map<String, String>> _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<Map<String, dynamic>> _normalizeListResponse(dynamic response) {
|
|
if (response == null) return [];
|
|
if (response is List<dynamic>) {
|
|
return response
|
|
.whereType<Map<String, dynamic>>()
|
|
.map((item) => Map<String, dynamic>.from(item))
|
|
.toList();
|
|
}
|
|
if (response is Map<String, dynamic>) return [response];
|
|
return [];
|
|
}
|
|
|
|
Future<void> resendVerificationEmail(String email) async {
|
|
await _callGoApi('/auth/resend-verification',
|
|
method: 'POST', body: {'email': email});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Category & Onboarding
|
|
// =========================================================================
|
|
|
|
Future<List<Category>> getCategories() async {
|
|
final data = await _callGoApi('/categories', method: 'GET');
|
|
return (data['categories'] as List)
|
|
.map((json) => Category.fromJson(json))
|
|
.toList();
|
|
}
|
|
|
|
Future<List<Category>> 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<bool> hasProfile() async {
|
|
final user = _authService.currentUser;
|
|
if (user == null) return false;
|
|
|
|
final data = await _callGoApi('/profile', method: 'GET');
|
|
return data['profile'] != null;
|
|
}
|
|
|
|
Future<bool> hasCategorySelection() async {
|
|
try {
|
|
final enabledIds = await _getEnabledCategoryIds();
|
|
return enabledIds.isNotEmpty;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> setUserCategorySettings({
|
|
required List<Category> categories,
|
|
required Set<String> 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<void> completeOnboarding() async {
|
|
await _callGoApi('/complete-onboarding', method: 'POST');
|
|
// Also update local storage so app knows immediately (AuthService)
|
|
await _authService.markOnboardingCompleteLocally();
|
|
}
|
|
|
|
Future<Set<String>> _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<Profile> 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<Map<String, dynamic>> 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<String, dynamic>),
|
|
'stats': ProfileStats.fromJson(data['stats'] as Map<String, dynamic>?),
|
|
'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<Map<String, dynamic>> getProfileById(String userId) async {
|
|
final data = await _callGoApi(
|
|
'/profiles/$userId',
|
|
method: 'GET',
|
|
);
|
|
|
|
return {
|
|
'profile': Profile.fromJson(data['profile'] as Map<String, dynamic>),
|
|
'stats': ProfileStats.fromJson(data['stats'] as Map<String, dynamic>?),
|
|
'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<Profile> updateProfile({
|
|
String? handle,
|
|
String? displayName,
|
|
String? bio,
|
|
String? location,
|
|
String? website,
|
|
List<String>? 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<ProfilePrivacySettings> 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<ProfilePrivacySettings> updatePrivacySettings(
|
|
ProfilePrivacySettings settings,
|
|
) async {
|
|
final data = await _callGoApi(
|
|
'/settings/privacy',
|
|
method: 'PATCH',
|
|
body: settings.toJson(),
|
|
);
|
|
return ProfilePrivacySettings.fromJson(data);
|
|
}
|
|
|
|
Future<UserSettings> 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<UserSettings> updateUserSettings(UserSettings settings) async {
|
|
final data = await _callGoApi(
|
|
'/settings/user',
|
|
method: 'PATCH',
|
|
body: settings.toJson(),
|
|
);
|
|
return UserSettings.fromJson(data);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Posts & Feed
|
|
// =========================================================================
|
|
|
|
Future<List<Post>> 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<String, dynamic>>()
|
|
.map((json) => Post.fromJson(json))
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
Future<Post> getPostById(String postId) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'GET',
|
|
);
|
|
|
|
return Post.fromJson(data['post']);
|
|
}
|
|
|
|
Future<List<Post>> 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<List<Post>> 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<List<Post>> 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<List<Post>> 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<List<Post>> 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<FocusContext> getPostFocusContext(String postId) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$postId/focus-context',
|
|
method: 'GET',
|
|
);
|
|
return FocusContext.fromJson(data);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Publishing - Unified Post/Beacon Flow
|
|
// =========================================================================
|
|
|
|
Future<Post> 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<Comment> 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<void> editPost({
|
|
required String postId,
|
|
required String content,
|
|
}) async {
|
|
await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'PATCH',
|
|
body: {'body': content},
|
|
);
|
|
}
|
|
|
|
Future<void> deletePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<void> updatePostVisibility({
|
|
required String postId,
|
|
required String visibility,
|
|
}) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/visibility',
|
|
method: 'PATCH',
|
|
body: {'visibility': visibility},
|
|
);
|
|
}
|
|
|
|
Future<void> updateAllPostVisibility(String visibility) async {
|
|
// Legacy function proxy for bulk update
|
|
await _callFunction(
|
|
'manage-post',
|
|
method: 'POST',
|
|
body: {
|
|
'action': 'bulk_update_privacy',
|
|
'visibility': visibility,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> pinPost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/pin',
|
|
method: 'POST',
|
|
body: {'pinned': true},
|
|
);
|
|
}
|
|
|
|
Future<void> unpinPost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/pin',
|
|
method: 'POST',
|
|
body: {'pinned': false}, // Assuming logic handles boolean toggles
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Beacons
|
|
// =========================================================================
|
|
|
|
Future<List<Post>> 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<void> vouchBeacon(String beaconId) async {
|
|
await _callGoApi(
|
|
'/beacons/$beaconId/vouch',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> reportBeacon(String beaconId) async {
|
|
await _callGoApi(
|
|
'/beacons/$beaconId/report',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> removeBeaconVote(String beaconId) async {
|
|
await _callGoApi(
|
|
'/beacons/$beaconId/vouch',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Social Actions
|
|
// =========================================================================
|
|
|
|
Future<String?> 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<void> unfollowUser(String userId) async {
|
|
await _callGoApi(
|
|
'/users/$userId/follow',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<List<FollowRequest>> getFollowRequests() async {
|
|
final data = await _callGoApi('/users/requests');
|
|
final requests = data['requests'] as List<dynamic>? ?? [];
|
|
return requests.map((e) => FollowRequest.fromJson(e)).toList();
|
|
}
|
|
|
|
Future<void> acceptFollowRequest(String requesterId) async {
|
|
await _callGoApi(
|
|
'/users/$requesterId/accept',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> rejectFollowRequest(String requesterId) async {
|
|
await _callGoApi(
|
|
'/users/$requesterId/reject',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<void> blockUser(String userId) async {
|
|
// Migrate to Go API
|
|
}
|
|
|
|
Future<void> unblockUser(String userId) async {
|
|
// Migrate to Go API
|
|
}
|
|
|
|
Future<void> appreciatePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/like',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> unappreciatePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/like',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Chat
|
|
// =========================================================================
|
|
|
|
Future<List<dynamic>> 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<Map<String, dynamic>> getConversationById(
|
|
String conversationId) async {
|
|
return {};
|
|
}
|
|
|
|
Future<String> getOrCreateConversation(String otherUserId) async {
|
|
final data = await _callGoApi('/conversation',
|
|
method: 'GET', queryParams: {'other_user_id': otherUserId});
|
|
return data['conversation_id'] as String;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> 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<List<dynamic>> 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<List<dynamic>> getMutualFollows() async {
|
|
final data = await _callGoApi('/mutual-follows', method: 'GET');
|
|
return data['profiles'] as List? ?? [];
|
|
}
|
|
|
|
Future<bool> 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<bool> 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<Map<String, dynamic>> 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<String, dynamic>;
|
|
final signedPrekey = data['signed_prekey'] as Map<String, dynamic>?;
|
|
final oneTimePrekey = data['one_time_prekey'] as Map<String, dynamic>?;
|
|
|
|
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<void> publishKeys({
|
|
required String identityKeyPublic,
|
|
required int registrationId,
|
|
String? preKey,
|
|
String? signedPrekeyPublic,
|
|
int? signedPrekeyId,
|
|
String? signedPrekeySignature,
|
|
List<dynamic>? 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<String> getSignedMediaUrl(String path) async {
|
|
// Migrate to Go API / Nginx Signed URLs
|
|
return '${ApiConfig.baseUrl}/media/signed?path=$path'; // Placeholder
|
|
}
|
|
|
|
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
|
final data = await callGoApi(
|
|
'/posts/$postId/reactions/toggle',
|
|
method: 'POST',
|
|
body: {'emoji': emoji},
|
|
);
|
|
if (data is Map<String, dynamic>) {
|
|
return data;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
Future<SearchResults> 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<ToneCheckResult> 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<List<Post>> 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<List<Post>> getsojornFeed({int limit = 20, int offset = 0}) async {
|
|
return getPersonalFeed(limit: limit, offset: offset);
|
|
}
|
|
|
|
Future<List<AppNotification>> 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<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> markNotificationsAsRead(List<String> ids) async {
|
|
await callGoApi(
|
|
'/notifications/read',
|
|
method: 'POST',
|
|
body: {'ids': ids},
|
|
);
|
|
}
|
|
|
|
Future<void> archiveNotifications(List<String> ids) async {
|
|
await callGoApi(
|
|
'/notifications/archive',
|
|
method: 'POST',
|
|
body: {'ids': ids},
|
|
);
|
|
}
|
|
|
|
Future<void> archiveAllNotifications() async {
|
|
await callGoApi(
|
|
'/notifications/archive-all',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Post Actions (Missing Methods)
|
|
// =========================================================================
|
|
|
|
Future<void> savePost(String postId) async {
|
|
await callGoApi(
|
|
'/posts/$postId/save',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> unsavePost(String postId) async {
|
|
await callGoApi(
|
|
'/posts/$postId/save',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
}
|