this repo has no description

feat: Enhance gallery creation with image upload and resizing functionality

+294 -30
+167 -4
lib/api.dart
··· 9 9 import 'package:http/http.dart' as http; 10 10 import 'dart:convert'; 11 11 import 'package:grain/dpop_client.dart'; 12 + import 'dart:io'; 13 + import 'package:mime/mime.dart'; 12 14 13 15 class ApiService { 14 16 String? _accessToken; ··· 131 133 final record = await xrpc.query( 132 134 service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 133 135 xrpc.NSID.create('notification.grain.social', 'getNotifications'), 134 - headers: {'Authorization': "Bearer \\$_accessToken"}, 136 + headers: {'Authorization': "Bearer $_accessToken"}, 135 137 to: (json) => 136 138 (json['notifications'] as List<dynamic>?) 137 139 ?.map( ··· 206 208 method: 'POST', 207 209 url: url, 208 210 accessToken: session.accessToken, 211 + headers: {'Content-Type': 'application/json'}, 209 212 body: jsonEncode(record), 210 213 ); 211 214 if (response.statusCode != 200 && response.statusCode != 201) { 212 215 appLogger.w( 213 - 'Failed to create gallery: ${response.statusCode} ${response.body}', 216 + 'Failed to create gallery: \\${response.statusCode} \\${response.body}', 214 217 ); 215 - throw Exception('Failed to create gallery: ${response.statusCode}'); 218 + throw Exception('Failed to create gallery: \\${response.statusCode}'); 216 219 } 217 220 final result = jsonDecode(response.body) as Map<String, dynamic>; 218 221 appLogger.i('Created gallery result: $result'); 219 - return result['uri']; 222 + final uri = result['uri'] as String?; 223 + return uri; 224 + } 225 + 226 + /// Polls the gallery until the number of items matches [expectedCount] or timeout. 227 + /// Returns the Gallery if successful, or null if timeout. 228 + Future<Gallery?> pollGalleryItems({ 229 + required String galleryUri, 230 + required int expectedCount, 231 + Duration pollDelay = const Duration(seconds: 2), 232 + int maxAttempts = 20, 233 + }) async { 234 + int attempts = 0; 235 + Gallery? gallery; 236 + while (attempts < maxAttempts) { 237 + gallery = await getGallery(uri: galleryUri); 238 + if (gallery != null && gallery.items.length == expectedCount) { 239 + appLogger.i( 240 + 'Gallery $galleryUri has expected number of items: $expectedCount', 241 + ); 242 + return gallery; 243 + } 244 + await Future.delayed(pollDelay); 245 + attempts++; 246 + } 247 + appLogger.w( 248 + 'Gallery $galleryUri did not reach expected items count ($expectedCount) after polling.', 249 + ); 250 + return null; 251 + } 252 + 253 + /// Uploads a blob (file) to the atproto uploadBlob endpoint using DPoP authentication. 254 + /// Returns the blob reference map on success, or null on failure. 255 + Future<Map<String, dynamic>?> uploadBlob(File file) async { 256 + final session = await auth.getValidSession(); 257 + if (session == null) { 258 + appLogger.w('No valid session for uploadBlob'); 259 + return null; 260 + } 261 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 262 + final issuer = session.issuer; 263 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.uploadBlob'); 264 + 265 + // Detect MIME type, fallback to application/octet-stream if unknown 266 + String? mimeType = lookupMimeType(file.path); 267 + final contentType = mimeType ?? 'application/octet-stream'; 268 + 269 + appLogger.i('Uploading blob: ${file.path} (MIME: $mimeType)'); 270 + 271 + final bytes = await file.readAsBytes(); 272 + 273 + final response = await dpopClient.send( 274 + method: 'POST', 275 + url: url, 276 + accessToken: session.accessToken, 277 + headers: {'Content-Type': contentType}, 278 + body: bytes, 279 + ); 280 + 281 + if (response.statusCode != 200 && response.statusCode != 201) { 282 + appLogger.w( 283 + 'Failed to upload blob: \\${response.statusCode} \\${response.body} (File: \\${file.path}, MIME: \\${mimeType})', 284 + ); 285 + return null; 286 + } 287 + 288 + try { 289 + final result = jsonDecode(response.body) as Map<String, dynamic>; 290 + appLogger.i('Uploaded blob result: $result'); 291 + return result; 292 + } catch (e, st) { 293 + appLogger.e('Failed to parse uploadBlob response: $e', stackTrace: st); 294 + return null; 295 + } 296 + } 297 + 298 + Future<String?> createPhoto({ 299 + required Map<String, dynamic> blob, 300 + required int width, 301 + required int height, 302 + String alt = '', 303 + }) async { 304 + final session = await auth.getValidSession(); 305 + if (session == null) { 306 + appLogger.w('No valid session for createPhotoRecord'); 307 + return null; 308 + } 309 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 310 + final issuer = session.issuer; 311 + final did = session.subject; 312 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 313 + final record = { 314 + 'collection': 'social.grain.photo', 315 + 'repo': did, 316 + 'record': { 317 + 'photo': blob['blob'], 318 + 'aspectRatio': {'width': width, 'height': height}, 319 + 'alt': "", 320 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 321 + }, 322 + }; 323 + appLogger.i('Creating photo record: $record'); 324 + final response = await dpopClient.send( 325 + method: 'POST', 326 + url: url, 327 + accessToken: session.accessToken, 328 + headers: {'Content-Type': 'application/json'}, 329 + body: jsonEncode(record), 330 + ); 331 + if (response.statusCode != 200 && response.statusCode != 201) { 332 + appLogger.w( 333 + 'Failed to create photo record: \\${response.statusCode} \\${response.body}', 334 + ); 335 + return null; 336 + } 337 + final result = jsonDecode(response.body) as Map<String, dynamic>; 338 + appLogger.i('Created photo record result: $result'); 339 + return result['uri'] as String?; 340 + } 341 + 342 + Future<String?> createGalleryItem({ 343 + required String galleryUri, 344 + required String photoUri, 345 + required int position, 346 + }) async { 347 + final session = await auth.getValidSession(); 348 + if (session == null) { 349 + appLogger.w('No valid session for createGalleryItem'); 350 + return null; 351 + } 352 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 353 + final issuer = session.issuer; 354 + final did = session.subject; 355 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 356 + final record = { 357 + 'collection': 'social.grain.gallery.item', 358 + 'repo': did, 359 + 'record': { 360 + 'gallery': galleryUri, 361 + 'item': photoUri, 362 + 'position': position, 363 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 364 + }, 365 + }; 366 + appLogger.i('Creating gallery item: $record'); 367 + final response = await dpopClient.send( 368 + method: 'POST', 369 + url: url, 370 + accessToken: session.accessToken, 371 + headers: {'Content-Type': 'application/json'}, 372 + body: jsonEncode(record), 373 + ); 374 + if (response.statusCode != 200 && response.statusCode != 201) { 375 + appLogger.w( 376 + 'Failed to create gallery item: \\${response.statusCode} \\${response.body}', 377 + ); 378 + return null; 379 + } 380 + final result = jsonDecode(response.body) as Map<String, dynamic>; 381 + appLogger.i('Created gallery item result: $result'); 382 + return result['uri'] as String?; 220 383 } 221 384 } 222 385
+17 -8
lib/auth.dart
··· 5 5 import 'package:grain/main.dart'; 6 6 import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 7 7 import 'package:grain/models/atproto_session.dart'; 8 - import 'package:jose/jose.dart'; 9 - import 'package:uuid/uuid.dart'; 10 - import 'package:http/http.dart' as http; 11 - import 'package:crypto/crypto.dart'; 12 8 13 9 class Auth { 14 10 static const _storage = FlutterSecureStorage(); ··· 64 60 } 65 61 66 62 Future<AtprotoSession?> getValidSession() async { 67 - final session = await _loadSession(); 63 + var session = await _loadSession(); 68 64 if (session == null || isSessionExpired(session)) { 69 - appLogger.w('Session is expired or not found'); 70 - // Try refresh or return null 71 - return null; 65 + appLogger.w('Session is expired or not found, attempting refresh'); 66 + // Try to refresh session by calling fetchSession 67 + try { 68 + final refreshed = await apiService.fetchSession(); 69 + if (refreshed != null && !isSessionExpired(refreshed)) { 70 + await _saveSession(refreshed); 71 + appLogger.i('Session refreshed and saved'); 72 + return refreshed; 73 + } else { 74 + appLogger.w('Session refresh failed or still expired'); 75 + return null; 76 + } 77 + } catch (e) { 78 + appLogger.e('Error refreshing session: $e'); 79 + return null; 80 + } 72 81 } 73 82 return session; 74 83 }
+2 -2
lib/dpop_client.dart
··· 110 110 ath: ath, 111 111 ); 112 112 113 + // Compose headers, allowing override of Content-Type for raw uploads 113 114 final requestHeaders = <String, String>{ 114 115 'Authorization': 'DPoP $accessToken', 115 116 'DPoP': proof, 116 - 'Content-Type': 'application/json', 117 117 if (headers != null) ...headers, 118 118 }; 119 119 ··· 129 129 response = await http.put(url, headers: requestHeaders, body: body); 130 130 break; 131 131 case 'DELETE': 132 - response = await http.delete(url, headers: requestHeaders); 132 + response = await http.delete(url, headers: requestHeaders, body: body); 133 133 break; 134 134 default: 135 135 throw UnsupportedError('Unsupported HTTP method: $method');
-1
lib/main.dart
··· 6 6 import 'package:grain/app_logger.dart'; 7 7 import 'package:grain/screens/splash_page.dart'; 8 8 import 'package:grain/screens/home_page.dart'; 9 - import 'package:grain/auth.dart'; 10 9 11 10 class AppConfig { 12 11 static late final String apiUrl;
lib/photo_manip.dart

This is a binary file and will not be displayed.

+15 -14
lib/screens/home_page.dart
··· 440 440 ), 441 441 floatingActionButton: 442 442 (!showProfile && !showNotifications && !showExplore) 443 - ? FloatingActionButton( 444 - onPressed: () { 445 - showModalBottomSheet( 446 - context: context, 447 - isScrollControlled: true, 448 - builder: (context) => CreateGalleryPage(), 449 - ); 450 - }, 451 - backgroundColor: const Color(0xFF0EA5E9), 452 - foregroundColor: Colors.white, 453 - child: const Icon(Icons.add_a_photo), 454 - tooltip: 'Create Gallery', 455 - ) 456 - : null, 443 + ? FloatingActionButton( 444 + shape: const CircleBorder(), 445 + onPressed: () { 446 + showModalBottomSheet( 447 + context: context, 448 + isScrollControlled: true, 449 + builder: (context) => CreateGalleryPage(), 450 + ); 451 + }, 452 + backgroundColor: const Color(0xFF0EA5E9), 453 + foregroundColor: Colors.white, 454 + child: const Icon(Icons.add_a_photo), 455 + tooltip: 'Create Gallery', 456 + ) 457 + : null, 457 458 ); 458 459 } 459 460 // Explore, Notifications, Profile: no tabs, no TabController
+41 -1
pubspec.lock
··· 1 1 # Generated by pub 2 2 # See https://dart.dev/tools/pub/glossary#lockfile 3 3 packages: 4 + archive: 5 + dependency: transitive 6 + description: 7 + name: archive 8 + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" 9 + url: "https://pub.dev" 10 + source: hosted 11 + version: "4.0.7" 4 12 asn1lib: 5 13 dependency: transitive 6 14 description: ··· 352 360 url: "https://pub.dev" 353 361 source: hosted 354 362 version: "4.1.2" 363 + image: 364 + dependency: "direct main" 365 + description: 366 + name: image 367 + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" 368 + url: "https://pub.dev" 369 + source: hosted 370 + version: "4.5.4" 355 371 image_picker: 356 372 dependency: "direct main" 357 373 description: ··· 505 521 source: hosted 506 522 version: "1.16.0" 507 523 mime: 508 - dependency: transitive 524 + dependency: "direct main" 509 525 description: 510 526 name: mime 511 527 sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" ··· 600 616 url: "https://pub.dev" 601 617 source: hosted 602 618 version: "2.3.0" 619 + petitparser: 620 + dependency: transitive 621 + description: 622 + name: petitparser 623 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" 624 + url: "https://pub.dev" 625 + source: hosted 626 + version: "6.1.0" 603 627 platform: 604 628 dependency: transitive 605 629 description: ··· 624 648 url: "https://pub.dev" 625 649 source: hosted 626 650 version: "3.9.1" 651 + posix: 652 + dependency: transitive 653 + description: 654 + name: posix 655 + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" 656 + url: "https://pub.dev" 657 + source: hosted 658 + version: "6.0.3" 627 659 quiver: 628 660 dependency: transitive 629 661 description: ··· 917 949 url: "https://pub.dev" 918 950 source: hosted 919 951 version: "1.1.0" 952 + xml: 953 + dependency: transitive 954 + description: 955 + name: xml 956 + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 957 + url: "https://pub.dev" 958 + source: hosted 959 + version: "6.5.0" 920 960 xrpc: 921 961 dependency: "direct main" 922 962 description:
+2
pubspec.yaml
··· 52 52 uuid: ^4.5.1 53 53 crypto: ^3.0.6 54 54 xrpc: ^0.6.1 55 + mime: ^1.0.6 56 + image: ^4.5.4 55 57 56 58 dev_dependencies: 57 59 flutter_test: