Change _reactionCountsFor and _myReactionsFor to prefer local state for immediate UI updates after toggle reactions, falling back to post model data when no local state exists.
975 lines
29 KiB
Dart
975 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.
|
|
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 caller for specialized edge endpoints. 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',
|
|
);
|
|
}
|
|
}
|