at main 27 kB view raw
1import 'dart:convert'; 2import 'dart:io'; 3 4import 'package:grain/app_logger.dart'; 5import 'package:grain/main.dart'; 6import 'package:grain/models/session.dart'; 7import 'package:http/http.dart' as http; 8import 'package:mime/mime.dart'; 9 10import './auth.dart'; 11import 'models/followers_result.dart'; 12import 'models/follows_result.dart'; 13import 'models/gallery.dart'; 14import 'models/gallery_photo.dart'; 15import 'models/gallery_thread.dart'; 16import 'models/notification.dart' as grain; 17import 'models/procedures/procedures.dart'; 18import 'models/profile.dart'; 19 20class ApiService { 21 Profile? currentUser; 22 Profile? loadedProfile; 23 List<Gallery> galleries = []; 24 25 String get _apiUrl => AppConfig.apiUrl; 26 27 Future<Session?> refreshSession(Session session) async { 28 final url = Uri.parse('$_apiUrl/api/token/refresh'); 29 final headers = {'Content-Type': 'application/json'}; 30 try { 31 final response = await http.post( 32 url, 33 headers: headers, 34 body: jsonEncode({'refreshToken': session.refreshToken}), 35 ); 36 if (response.statusCode == 200) { 37 appLogger.i('Session refreshed successfully'); 38 return Session.fromJson(jsonDecode(response.body)); 39 } else { 40 appLogger.w('Failed to refresh session: ${response.statusCode} ${response.body}'); 41 return null; 42 } 43 } catch (e) { 44 appLogger.e('Error refreshing session: $e'); 45 return null; 46 } 47 } 48 49 Future<bool> revokeSession(Session session) async { 50 final url = Uri.parse('$_apiUrl/api/token/revoke'); 51 final headers = {'Content-Type': 'application/json'}; 52 try { 53 final response = await http.post( 54 url, 55 headers: headers, 56 body: jsonEncode({'refreshToken': session.refreshToken}), 57 ); 58 if (response.statusCode == 200) { 59 appLogger.i('Session revoked successfully'); 60 return true; 61 } else { 62 appLogger.w('Failed to revoke session: ${response.statusCode} ${response.body}'); 63 return false; 64 } 65 } catch (e) { 66 appLogger.e('Error revoking session: $e'); 67 return false; 68 } 69 } 70 71 Future<Profile?> fetchCurrentUser() async { 72 final session = await auth.getValidSession(); 73 74 if (session == null || session.did.isEmpty) { 75 return null; 76 } 77 78 final user = await fetchProfile(did: session.did); 79 80 currentUser = user; 81 82 return user; 83 } 84 85 Future<Profile?> fetchProfile({required String did}) async { 86 final session = await auth.getValidSession(); 87 final token = session?.token; 88 appLogger.i('Fetching profile for did: $did'); 89 final response = await http.get( 90 Uri.parse('$_apiUrl/xrpc/social.grain.actor.getProfile?actor=$did'), 91 headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $token"}, 92 ); 93 if (response.statusCode != 200) { 94 appLogger.w('Failed to fetch profile: ${response.statusCode} ${response.body}'); 95 return null; 96 } 97 return Profile.fromJson(jsonDecode(response.body)); 98 } 99 100 Future<List<Gallery>> fetchActorGalleries({required String did}) async { 101 appLogger.i('Fetching galleries for actor did: $did'); 102 final response = await http.get( 103 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getActorGalleries?actor=$did'), 104 headers: {'Content-Type': 'application/json'}, 105 ); 106 if (response.statusCode != 200) { 107 appLogger.w('Failed to fetch galleries: ${response.statusCode} ${response.body}'); 108 return []; 109 } 110 final json = jsonDecode(response.body); 111 galleries = 112 (json['items'] as List<dynamic>?)?.map((item) => Gallery.fromJson(item)).toList() ?? []; 113 return galleries; 114 } 115 116 Future<List<GalleryPhoto>> fetchActorPhotos({required String did}) async { 117 appLogger.i('Fetching photos for actor did: $did'); 118 final response = await http.get( 119 Uri.parse('$_apiUrl/xrpc/social.grain.photo.getActorPhotos?actor=$did'), 120 headers: {'Content-Type': 'application/json'}, 121 ); 122 if (response.statusCode != 200) { 123 appLogger.w('Failed to fetch photos: ${response.statusCode} ${response.body}'); 124 return []; 125 } 126 final json = jsonDecode(response.body); 127 return (json['items'] as List<dynamic>?)?.map((item) => GalleryPhoto.fromJson(item)).toList() ?? 128 []; 129 } 130 131 Future<List<Gallery>> getTimeline({String? algorithm}) async { 132 final session = await auth.getValidSession(); 133 final token = session?.token; 134 if (token == null) { 135 return []; 136 } 137 appLogger.i('Fetching timeline with algorithm: ${algorithm ?? 'default'}'); 138 final uri = algorithm != null 139 ? Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline?algorithm=$algorithm') 140 : Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline'); 141 final response = await http.get( 142 uri, 143 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 144 ); 145 if (response.statusCode != 200) { 146 appLogger.w('Failed to fetch timeline: ${response.statusCode} ${response.body}'); 147 return []; 148 } 149 final json = jsonDecode(response.body); 150 return (json['feed'] as List<dynamic>?) 151 ?.map((item) => Gallery.fromJson(item as Map<String, dynamic>)) 152 .toList() ?? 153 []; 154 } 155 156 Future<Gallery?> getGallery({required String uri}) async { 157 appLogger.i('Fetching gallery for uri: $uri'); 158 final session = await auth.getValidSession(); 159 final token = session?.token; 160 if (token == null) { 161 appLogger.w('No access token for getGallery'); 162 return null; 163 } 164 final response = await http.get( 165 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGallery?uri=$uri'), 166 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 167 ); 168 if (response.statusCode != 200) { 169 appLogger.w('Failed to fetch gallery: ${response.statusCode} ${response.body}'); 170 return null; 171 } 172 try { 173 final json = jsonDecode(response.body); 174 if (json is Map<String, dynamic>) { 175 return Gallery.fromJson(json); 176 } else { 177 appLogger.w('Unexpected response type for getGallery: ${response.body}'); 178 return null; 179 } 180 } catch (e, st) { 181 appLogger.e('Error parsing getGallery response: $e', stackTrace: st); 182 return null; 183 } 184 } 185 186 Future<GalleryThread?> getGalleryThread({required String uri}) async { 187 appLogger.i('Fetching gallery thread for uri: $uri'); 188 final session = await auth.getValidSession(); 189 final token = session?.token; 190 if (token == null) { 191 appLogger.w('No access token for getGalleryThread'); 192 return null; 193 } 194 final response = await http.get( 195 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 196 headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $token"}, 197 ); 198 if (response.statusCode != 200) { 199 appLogger.w('Failed to fetch gallery thread: ${response.statusCode} ${response.body}'); 200 return null; 201 } 202 final json = jsonDecode(response.body) as Map<String, dynamic>; 203 return GalleryThread.fromJson(json); 204 } 205 206 Future<List<grain.Notification>> getNotifications() async { 207 final session = await auth.getValidSession(); 208 final token = session?.token; 209 if (token == null) { 210 appLogger.w('No access token for getNotifications'); 211 return []; 212 } 213 appLogger.i('Fetching notifications'); 214 final response = await http.get( 215 Uri.parse('$_apiUrl/xrpc/social.grain.notification.getNotifications'), 216 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 217 ); 218 if (response.statusCode != 200) { 219 appLogger.w('Failed to fetch notifications: ${response.statusCode} ${response.body}'); 220 return []; 221 } 222 final json = jsonDecode(response.body); 223 return (json['notifications'] as List<dynamic>?) 224 ?.map((item) { 225 final map = item as Map<String, dynamic>; 226 final reasonSubject = map['reasonSubject']; 227 if (reasonSubject != null && reasonSubject is Map<String, dynamic>) { 228 final type = 229 reasonSubject['\$type'] ?? reasonSubject[r'$type'] ?? reasonSubject['type']; 230 switch (type) { 231 case 'social.grain.gallery.defs#galleryView': 232 map['reasonSubjectGallery'] = map['reasonSubject']; 233 break; 234 case 'social.grain.actor.defs#profileView': 235 map['reasonSubjectProfile'] = map['reasonSubject']; 236 break; 237 case 'social.grain.comment.defs#commentView': 238 map['reasonSubjectComment'] = map['reasonSubject']; 239 break; 240 } 241 } 242 map.remove('reasonSubject'); 243 try { 244 return grain.Notification.fromJson(map); 245 } catch (e, st) { 246 appLogger.e('Failed to deserialize notification: $e', stackTrace: st); 247 return null; 248 } 249 }) 250 .whereType<grain.Notification>() 251 .toList() ?? 252 []; 253 } 254 255 Future<List<Profile>> searchActors(String query) async { 256 final session = await auth.getValidSession(); 257 final token = session?.token; 258 if (token == null) { 259 appLogger.w('No access token for searchActors'); 260 return []; 261 } 262 appLogger.i('Searching actors with query: $query'); 263 final response = await http.get( 264 Uri.parse('$_apiUrl/xrpc/social.grain.actor.searchActors?q=$query'), 265 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 266 ); 267 if (response.statusCode != 200) { 268 appLogger.w('Failed to search actors: ${response.statusCode} ${response.body}'); 269 return []; 270 } 271 final json = jsonDecode(response.body); 272 return (json['actors'] as List<dynamic>?)?.map((item) => Profile.fromJson(item)).toList() ?? []; 273 } 274 275 Future<List<Gallery>> getActorFavs({required String did}) async { 276 appLogger.i('Fetching actor favs for did: $did'); 277 final response = await http.get( 278 Uri.parse('$_apiUrl/xrpc/social.grain.actor.getActorFavs?actor=$did'), 279 headers: {'Content-Type': 'application/json'}, 280 ); 281 if (response.statusCode != 200) { 282 appLogger.w('Failed to fetch actor favs: ${response.statusCode} ${response.body}'); 283 return []; 284 } 285 final json = jsonDecode(response.body); 286 return (json['items'] as List<dynamic>?)?.map((item) => Gallery.fromJson(item)).toList() ?? []; 287 } 288 289 /// Fetch followers for a given actor DID 290 Future<FollowersResult> getFollowers({ 291 required String actor, 292 String? cursor, 293 int limit = 50, 294 }) async { 295 final uri = Uri.parse( 296 '$_apiUrl/xrpc/social.grain.graph.getFollowers?actor=$actor&limit=$limit${cursor != null ? '&cursor=$cursor' : ''}', 297 ); 298 final response = await http.get(uri, headers: {'Content-Type': 'application/json'}); 299 if (response.statusCode != 200) { 300 throw Exception('Failed to fetch followers: \\${response.statusCode} \\${response.body}'); 301 } 302 final json = jsonDecode(response.body); 303 return FollowersResult.fromJson(json); 304 } 305 306 /// Fetch follows for a given actor DID 307 Future<FollowsResult> getFollows({required String actor, String? cursor, int limit = 50}) async { 308 final uri = Uri.parse( 309 '$_apiUrl/xrpc/social.grain.graph.getFollows?actor=$actor&limit=$limit${cursor != null ? '&cursor=$cursor' : ''}', 310 ); 311 final response = await http.get(uri, headers: {'Content-Type': 'application/json'}); 312 if (response.statusCode != 200) { 313 throw Exception('Failed to fetch follows: \\${response.statusCode} \\${response.body}'); 314 } 315 final json = jsonDecode(response.body); 316 return FollowsResult.fromJson(json); 317 } 318 319 // Procedures 320 321 Future<UpdateProfileResponse> updateProfile({required UpdateProfileRequest request}) async { 322 final session = await auth.getValidSession(); 323 final token = session?.token; 324 if (token == null) { 325 throw Exception('No access token for updateProfile'); 326 } 327 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.actor.updateProfile'); 328 final response = await http.post( 329 uri, 330 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 331 body: jsonEncode(request.toJson()), 332 ); 333 if (response.statusCode != 200) { 334 throw Exception('Failed to update profile: ${response.statusCode} ${response.body}'); 335 } 336 final json = jsonDecode(response.body); 337 return UpdateProfileResponse.fromJson(json); 338 } 339 340 Future<UpdateAvatarResponse> updateAvatar({required File avatarFile}) async { 341 final session = await auth.getValidSession(); 342 final token = session?.token; 343 if (token == null) { 344 throw Exception('No access token for updateAvatar'); 345 } 346 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.actor.updateAvatar'); 347 String? mimeType = lookupMimeType(avatarFile.path); 348 final contentType = mimeType ?? 'application/octet-stream'; 349 final bytes = await avatarFile.readAsBytes(); 350 final response = await http.post( 351 uri, 352 headers: {'Authorization': "Bearer $token", 'Content-Type': contentType}, 353 body: bytes, 354 ); 355 if (response.statusCode != 200) { 356 throw Exception('Failed to update avatar: ${response.statusCode} ${response.body}'); 357 } 358 final json = jsonDecode(response.body); 359 return UpdateAvatarResponse.fromJson(json); 360 } 361 362 Future<ApplySortResponse> applySort({required ApplySortRequest request}) async { 363 final session = await auth.getValidSession(); 364 final token = session?.token; 365 if (token == null) { 366 throw Exception('No access token for applySort'); 367 } 368 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.applySort'); 369 final response = await http.post( 370 uri, 371 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 372 body: jsonEncode(request.toJson()), 373 ); 374 if (response.statusCode != 200) { 375 throw Exception('Failed to apply sort: ${response.statusCode} ${response.body}'); 376 } 377 final json = jsonDecode(response.body); 378 return ApplySortResponse.fromJson(json); 379 } 380 381 Future<ApplyAltsResponse> applyAlts({required ApplyAltsRequest request}) async { 382 final session = await auth.getValidSession(); 383 final token = session?.token; 384 if (token == null) { 385 throw Exception('No access token for applyAlts'); 386 } 387 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.photo.applyAlts'); 388 final response = await http.post( 389 uri, 390 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 391 body: jsonEncode(request.toJson()), 392 ); 393 if (response.statusCode != 200) { 394 throw Exception('Failed to apply alts: ${response.statusCode} ${response.body}'); 395 } 396 final json = jsonDecode(response.body); 397 return ApplyAltsResponse.fromJson(json); 398 } 399 400 Future<CreateExifResponse> createExif({required CreateExifRequest request}) async { 401 final session = await auth.getValidSession(); 402 final token = session?.token; 403 if (token == null) { 404 throw Exception('No access token for createExif'); 405 } 406 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.photo.createExif'); 407 final response = await http.post( 408 uri, 409 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 410 body: jsonEncode(request.toJson()), 411 ); 412 if (response.statusCode != 200) { 413 throw Exception('Failed to create exif: ${response.statusCode} ${response.body}'); 414 } 415 final json = jsonDecode(response.body); 416 return CreateExifResponse.fromJson(json); 417 } 418 419 Future<CreateFollowResponse> createFollow({required CreateFollowRequest request}) async { 420 final session = await auth.getValidSession(); 421 final token = session?.token; 422 if (token == null) { 423 throw Exception('No access token for createFollow'); 424 } 425 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.graph.createFollow'); 426 final response = await http.post( 427 uri, 428 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 429 body: jsonEncode(request.toJson()), 430 ); 431 if (response.statusCode != 200) { 432 throw Exception('Failed to create follow: ${response.statusCode} ${response.body}'); 433 } 434 final json = jsonDecode(response.body); 435 return CreateFollowResponse.fromJson(json); 436 } 437 438 Future<DeleteFollowResponse> deleteFollow({required DeleteFollowRequest request}) async { 439 final session = await auth.getValidSession(); 440 final token = session?.token; 441 if (token == null) { 442 throw Exception('No access token for deleteFollow'); 443 } 444 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.graph.deleteFollow'); 445 final response = await http.post( 446 uri, 447 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 448 body: jsonEncode(request.toJson()), 449 ); 450 if (response.statusCode != 200) { 451 throw Exception('Failed to delete follow: {response.statusCode} {response.body}'); 452 } 453 final json = jsonDecode(response.body); 454 return DeleteFollowResponse.fromJson(json); 455 } 456 457 Future<DeletePhotoResponse> deletePhoto({required DeletePhotoRequest request}) async { 458 final session = await auth.getValidSession(); 459 final token = session?.token; 460 if (token == null) { 461 throw Exception('No access token for deletePhoto'); 462 } 463 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.photo.deletePhoto'); 464 final response = await http.post( 465 uri, 466 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 467 body: jsonEncode(request.toJson()), 468 ); 469 if (response.statusCode != 200) { 470 throw Exception('Failed to delete photo: ${response.statusCode} ${response.body}'); 471 } 472 final json = jsonDecode(response.body); 473 return DeletePhotoResponse.fromJson(json); 474 } 475 476 Future<UploadPhotoResponse> uploadPhoto(File file) async { 477 final session = await auth.getValidSession(); 478 if (session == null) { 479 appLogger.w('No valid session for uploadPhoto'); 480 throw Exception('No valid session for uploadPhoto'); 481 } 482 final token = session.token; 483 final uri = Uri.parse('${AppConfig.apiUrl}/xrpc/social.grain.photo.uploadPhoto'); 484 485 // Detect MIME type, fallback to application/octet-stream if unknown 486 String? mimeType = lookupMimeType(file.path); 487 final contentType = mimeType ?? 'application/octet-stream'; 488 489 appLogger.i('Uploading photo: ${file.path} (MIME: $mimeType)'); 490 final bytes = await file.readAsBytes(); 491 492 final response = await http.post( 493 uri, 494 headers: {'Authorization': 'Bearer $token', 'Content-Type': contentType}, 495 body: bytes, 496 ); 497 498 if (response.statusCode != 200 && response.statusCode != 201) { 499 appLogger.w( 500 'Failed to upload photo: ${response.statusCode} ${response.body} (File: ${file.path}, MIME: $mimeType)', 501 ); 502 throw Exception('Failed to upload photo: ${response.statusCode} ${response.body}'); 503 } 504 505 try { 506 final json = jsonDecode(response.body); 507 appLogger.i('Uploaded photo result: $json'); 508 return UploadPhotoResponse.fromJson(json); 509 } catch (e, st) { 510 appLogger.e('Failed to parse createPhoto response: $e', stackTrace: st); 511 throw Exception('Failed to parse createPhoto response: $e'); 512 } 513 } 514 515 Future<DeleteGalleryItemResponse> deleteGalleryItem({ 516 required DeleteGalleryItemRequest request, 517 }) async { 518 final session = await auth.getValidSession(); 519 final token = session?.token; 520 if (token == null) { 521 throw Exception('No access token for deleteGalleryItem'); 522 } 523 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.deleteItem'); 524 final response = await http.post( 525 uri, 526 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 527 body: jsonEncode(request.toJson()), 528 ); 529 if (response.statusCode != 200) { 530 throw Exception('Failed to delete gallery item: ${response.statusCode} ${response.body}'); 531 } 532 final json = jsonDecode(response.body); 533 return DeleteGalleryItemResponse.fromJson(json); 534 } 535 536 Future<CreateGalleryItemResponse> createGalleryItem({ 537 required CreateGalleryItemRequest request, 538 }) async { 539 final session = await auth.getValidSession(); 540 final token = session?.token; 541 if (token == null) { 542 throw Exception('No access token for createGalleryItem'); 543 } 544 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.createItem'); 545 final response = await http.post( 546 uri, 547 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 548 body: jsonEncode(request.toJson()), 549 ); 550 if (response.statusCode != 200) { 551 throw Exception('Failed to create gallery item: ${response.statusCode} ${response.body}'); 552 } 553 final json = jsonDecode(response.body); 554 return CreateGalleryItemResponse.fromJson(json); 555 } 556 557 Future<UpdateGalleryResponse> updateGallery({required UpdateGalleryRequest request}) async { 558 final session = await auth.getValidSession(); 559 final token = session?.token; 560 if (token == null) { 561 throw Exception('No access token for updateGallery'); 562 } 563 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.updateGallery'); 564 final response = await http.post( 565 uri, 566 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 567 body: jsonEncode(request.toJson()), 568 ); 569 if (response.statusCode != 200) { 570 throw Exception('Failed to update gallery: ${response.statusCode} ${response.body}'); 571 } 572 final json = jsonDecode(response.body); 573 return UpdateGalleryResponse.fromJson(json); 574 } 575 576 Future<DeleteGalleryResponse> deleteGallery({required DeleteGalleryRequest request}) async { 577 final session = await auth.getValidSession(); 578 final token = session?.token; 579 if (token == null) { 580 throw Exception('No access token for deleteGallery'); 581 } 582 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.deleteGallery'); 583 final response = await http.post( 584 uri, 585 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 586 body: jsonEncode(request.toJson()), 587 ); 588 if (response.statusCode != 200) { 589 throw Exception('Failed to delete gallery: ${response.statusCode} ${response.body}'); 590 } 591 final json = jsonDecode(response.body); 592 return DeleteGalleryResponse.fromJson(json); 593 } 594 595 Future<CreateGalleryResponse> createGallery({required CreateGalleryRequest request}) async { 596 final session = await auth.getValidSession(); 597 final token = session?.token; 598 if (token == null) { 599 throw Exception('No access token for createGallery'); 600 } 601 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.gallery.createGallery'); 602 final response = await http.post( 603 uri, 604 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 605 body: jsonEncode(request.toJson()), 606 ); 607 if (response.statusCode != 200) { 608 throw Exception('Failed to create gallery: ${response.statusCode} ${response.body}'); 609 } 610 final json = jsonDecode(response.body); 611 return CreateGalleryResponse.fromJson(json); 612 } 613 614 Future<DeleteFavoriteResponse> deleteFavorite({required DeleteFavoriteRequest request}) async { 615 final session = await auth.getValidSession(); 616 final token = session?.token; 617 if (token == null) { 618 throw Exception('No access token for deleteFavorite'); 619 } 620 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.favorite.deleteFavorite'); 621 final response = await http.post( 622 uri, 623 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 624 body: jsonEncode(request.toJson()), 625 ); 626 if (response.statusCode != 200) { 627 throw Exception('Failed to delete favorite: ${response.statusCode} ${response.body}'); 628 } 629 final json = jsonDecode(response.body); 630 return DeleteFavoriteResponse.fromJson(json); 631 } 632 633 Future<CreateFavoriteResponse> createFavorite({required CreateFavoriteRequest request}) async { 634 final session = await auth.getValidSession(); 635 final token = session?.token; 636 if (token == null) { 637 throw Exception('No access token for createFavorite'); 638 } 639 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.favorite.createFavorite'); 640 final response = await http.post( 641 uri, 642 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 643 body: jsonEncode(request.toJson()), 644 ); 645 if (response.statusCode != 200) { 646 throw Exception('Failed to create favorite: ${response.statusCode} ${response.body}'); 647 } 648 final json = jsonDecode(response.body); 649 return CreateFavoriteResponse.fromJson(json); 650 } 651 652 Future<DeleteCommentResponse> deleteComment({required DeleteCommentRequest request}) async { 653 final session = await auth.getValidSession(); 654 final token = session?.token; 655 if (token == null) { 656 throw Exception('No access token for deleteComment'); 657 } 658 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.comment.deleteComment'); 659 final response = await http.post( 660 uri, 661 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 662 body: jsonEncode(request.toJson()), 663 ); 664 if (response.statusCode != 200) { 665 throw Exception('Failed to delete comment: ${response.statusCode} ${response.body}'); 666 } 667 final json = jsonDecode(response.body); 668 return DeleteCommentResponse.fromJson(json); 669 } 670 671 Future<CreateCommentResponse> createComment({required CreateCommentRequest request}) async { 672 final session = await auth.getValidSession(); 673 final token = session?.token; 674 if (token == null) { 675 throw Exception('No access token for createComment'); 676 } 677 final uri = Uri.parse('$_apiUrl/xrpc/social.grain.comment.createComment'); 678 final response = await http.post( 679 uri, 680 headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 681 body: jsonEncode(request.toJson()), 682 ); 683 if (response.statusCode != 200) { 684 throw Exception('Failed to create comment: ${response.statusCode} ${response.body}'); 685 } 686 final json = jsonDecode(response.body); 687 return CreateCommentResponse.fromJson(json); 688 } 689 690 Future<bool> updateSeen() async { 691 final session = await auth.getValidSession(); 692 final token = session?.token; 693 if (token == null) { 694 appLogger.w('No access token for updateSeen'); 695 return false; 696 } 697 final url = Uri.parse('$_apiUrl/xrpc/social.grain.notification.updateSeen'); 698 final seenAt = DateTime.now().toUtc().toIso8601String(); 699 final body = jsonEncode({'seenAt': seenAt}); 700 final headers = {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'}; 701 try { 702 final response = await http.post(url, headers: headers, body: body); 703 if (response.statusCode == 200) { 704 appLogger.i('Successfully updated seen notifications at $seenAt'); 705 return true; 706 } else { 707 appLogger.w('Failed to update seen notifications: ${response.statusCode} ${response.body}'); 708 return false; 709 } 710 } catch (e) { 711 appLogger.e('Error updating seen notifications: $e'); 712 return false; 713 } 714 } 715} 716 717final apiService = ApiService();