sojorn/sojorn_app/lib/services/api_service.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

934 lines
28 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();
}
// =========================================================================
// 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 {
final data = await _callGoApi(
'/posts/$postId/comments',
method: 'POST',
body: {'body': body},
);
return Comment.fromJson(data['comment']);
}
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 still Supabase RPC or migrate?
// Summary didn't mention beacon voting endpoint. Keeping legacy RPC.
Future<void> vouchBeacon(String beaconId) async {
// Migrate to Go API
}
Future<void> reportBeacon(String beaconId) async {
// Migrate to Go API
}
Future<void> removeBeaconVote(String beaconId) async {
// Migrate to Go API
}
// =========================================================================
// 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<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',
);
}
}