[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: media picker in camera

+773 -1
+4
android/app/src/main/AndroidManifest.xml
··· 1 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> 2 2 <!-- Add Internet permission for video streaming --> 3 3 <uses-permission android:name="android.permission.INTERNET"/> 4 + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> 5 + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> 6 + <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> 7 + <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/> 4 8 <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/> 5 9 <uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove"/> 6 10 <uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove"/>
+49 -1
lib/src/core/design_system/templates/recording_page_template.dart
··· 19 19 required this.onFlipCamera, 20 20 required this.canFlipCamera, 21 21 required this.captureMode, 22 + this.onOpenLibrary, 22 23 this.onTap, 23 24 this.onRecordStart, 24 25 this.onRecordStop, ··· 34 35 final VoidCallback? onFlipCamera; 35 36 final bool canFlipCamera; 36 37 final CaptureMode captureMode; 38 + final VoidCallback? onOpenLibrary; 37 39 38 40 /// Called on tap. In videoOnly: toggle recording. In hybrid: take photo. 39 41 final VoidCallback? onTap; ··· 92 94 // Bottom overlay sits inside rounded view 93 95 _BottomOverlay( 94 96 onFlipCamera: canFlipCamera ? onFlipCamera : null, 97 + onOpenLibrary: onOpenLibrary, 95 98 recordingButton: RecordingButton( 96 99 isRecording: isRecording, 97 100 mode: captureMode, ··· 184 187 class _BottomOverlay extends StatelessWidget { 185 188 const _BottomOverlay({ 186 189 required this.onFlipCamera, 190 + required this.onOpenLibrary, 187 191 required this.recordingButton, 188 192 required this.bottomPadding, 189 193 }); 190 194 191 195 final VoidCallback? onFlipCamera; 196 + final VoidCallback? onOpenLibrary; 192 197 final Widget recordingButton; 193 198 final double bottomPadding; 194 199 ··· 221 226 else 222 227 const SizedBox(width: 80), 223 228 recordingButton, 224 - const SizedBox(width: 80), 229 + if (onOpenLibrary != null) 230 + _LibraryButton(onPressed: onOpenLibrary!) 231 + else 232 + const SizedBox(width: 80), 225 233 ], 226 234 ), 227 235 ), ··· 269 277 ); 270 278 } 271 279 } 280 + 281 + class _LibraryButton extends StatelessWidget { 282 + const _LibraryButton({required this.onPressed}); 283 + 284 + final VoidCallback onPressed; 285 + 286 + @override 287 + Widget build(BuildContext context) { 288 + return GestureDetector( 289 + onTap: () { 290 + HapticFeedback.lightImpact(); 291 + onPressed(); 292 + }, 293 + child: SizedBox( 294 + width: 80, 295 + height: 80, 296 + child: Center( 297 + child: ClipOval( 298 + child: BackdropFilter( 299 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 300 + child: Container( 301 + width: 50, 302 + height: 50, 303 + decoration: BoxDecoration( 304 + shape: BoxShape.circle, 305 + color: Colors.black.withAlpha(90), 306 + ), 307 + child: const Icon( 308 + Icons.photo_library_outlined, 309 + color: Colors.white, 310 + size: 24, 311 + ), 312 + ), 313 + ), 314 + ), 315 + ), 316 + ), 317 + ); 318 + } 319 + }
+26
lib/src/features/posting/ui/models/media_selection.dart
··· 1 + import 'dart:collection'; 2 + 3 + import 'package:image_picker/image_picker.dart'; 4 + 5 + sealed class MediaLibrarySelection { 6 + const MediaLibrarySelection(); 7 + } 8 + 9 + class SinglePhotoSelection extends MediaLibrarySelection { 10 + const SinglePhotoSelection(this.photo); 11 + 12 + final XFile photo; 13 + } 14 + 15 + class SingleVideoSelection extends MediaLibrarySelection { 16 + const SingleVideoSelection(this.video); 17 + 18 + final XFile video; 19 + } 20 + 21 + class MultiPhotoSelection extends MediaLibrarySelection { 22 + MultiPhotoSelection(List<XFile> photos) 23 + : photos = UnmodifiableListView<XFile>(photos); 24 + 25 + final List<XFile> photos; 26 + }
+604
lib/src/features/posting/ui/pages/media_picker_page.dart
··· 1 + import 'dart:typed_data'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:image_picker/image_picker.dart'; 6 + import 'package:photo_manager/photo_manager.dart'; 7 + import 'package:spark/src/core/utils/logging/logging.dart'; 8 + import 'package:spark/src/features/posting/ui/models/media_selection.dart'; 9 + 10 + Future<MediaLibrarySelection?> showMediaLibraryPickerSheet( 11 + BuildContext context, { 12 + bool showMultiPhotoButton = true, 13 + int maxMultiPhotoSelection = 12, 14 + }) { 15 + return showModalBottomSheet<MediaLibrarySelection>( 16 + context: context, 17 + isScrollControlled: true, 18 + useSafeArea: true, 19 + backgroundColor: Colors.transparent, 20 + builder: (context) { 21 + return SizedBox( 22 + height: MediaQuery.sizeOf(context).height * 0.9, 23 + child: MediaLibraryPickerPage( 24 + showMultiPhotoButton: showMultiPhotoButton, 25 + maxMultiPhotoSelection: maxMultiPhotoSelection, 26 + ), 27 + ); 28 + }, 29 + ); 30 + } 31 + 32 + class MediaLibraryPickerPage extends StatefulWidget { 33 + const MediaLibraryPickerPage({ 34 + this.showMultiPhotoButton = true, 35 + this.maxMultiPhotoSelection = 12, 36 + super.key, 37 + }); 38 + 39 + final bool showMultiPhotoButton; 40 + final int maxMultiPhotoSelection; 41 + 42 + @override 43 + State<MediaLibraryPickerPage> createState() => _MediaLibraryPickerPageState(); 44 + } 45 + 46 + class _MediaLibraryPickerPageState extends State<MediaLibraryPickerPage> { 47 + static const int _pageSize = 80; 48 + 49 + final ScrollController _scrollController = ScrollController(); 50 + final List<AssetEntity> _assets = <AssetEntity>[]; 51 + final List<AssetEntity> _selectedPhotoAssets = <AssetEntity>[]; 52 + late final SparkLogger _logger; 53 + 54 + AssetPathEntity? _assetPath; 55 + bool _isLoading = true; 56 + bool _hasPermission = false; 57 + bool _isLimitedPermission = false; 58 + bool _isLoadingMore = false; 59 + bool _hasMore = true; 60 + bool _isMultiPhotoSelection = false; 61 + int _currentPage = 0; 62 + String? _errorMessage; 63 + 64 + @override 65 + void initState() { 66 + super.initState(); 67 + _logger = GetIt.instance<LogService>().getLogger('MediaLibraryPickerPage'); 68 + _scrollController.addListener(_onScroll); 69 + _requestPermissionAndLoadAssets(); 70 + } 71 + 72 + @override 73 + void dispose() { 74 + _scrollController 75 + ..removeListener(_onScroll) 76 + ..dispose(); 77 + super.dispose(); 78 + } 79 + 80 + void _onScroll() { 81 + if (!_scrollController.hasClients) return; 82 + final position = _scrollController.position; 83 + final triggerOffset = position.maxScrollExtent - 600; 84 + if (position.pixels >= triggerOffset) { 85 + _loadNextPage(); 86 + } 87 + } 88 + 89 + Future<void> _requestPermissionAndLoadAssets() async { 90 + try { 91 + setState(() { 92 + _isLoading = true; 93 + _errorMessage = null; 94 + _assets.clear(); 95 + _selectedPhotoAssets.clear(); 96 + _assetPath = null; 97 + _currentPage = 0; 98 + _hasMore = true; 99 + }); 100 + 101 + final permissionState = await PhotoManager.requestPermissionExtend(); 102 + if (!mounted) return; 103 + 104 + if (!permissionState.isAuth && !permissionState.hasAccess) { 105 + setState(() { 106 + _hasPermission = false; 107 + _isLimitedPermission = false; 108 + _isLoading = false; 109 + }); 110 + return; 111 + } 112 + 113 + final paths = await PhotoManager.getAssetPathList(); 114 + if (!mounted) return; 115 + 116 + _hasPermission = true; 117 + _isLimitedPermission = permissionState == PermissionState.limited; 118 + 119 + if (paths.isEmpty) { 120 + setState(() { 121 + _isLoading = false; 122 + _hasMore = false; 123 + }); 124 + return; 125 + } 126 + 127 + _assetPath = paths.first; 128 + await _loadNextPage(); 129 + 130 + if (!mounted) return; 131 + if (_isLoading) { 132 + setState(() { 133 + _isLoading = false; 134 + }); 135 + } 136 + } catch (e, stackTrace) { 137 + _logger.e( 138 + 'Failed to load media library', 139 + error: e, 140 + stackTrace: stackTrace, 141 + ); 142 + if (!mounted) return; 143 + setState(() { 144 + _isLoading = false; 145 + _errorMessage = 'Unable to load your photo library.'; 146 + }); 147 + } 148 + } 149 + 150 + Future<void> _loadNextPage() async { 151 + if (_assetPath == null || _isLoadingMore || !_hasMore) { 152 + return; 153 + } 154 + 155 + setState(() { 156 + _isLoadingMore = true; 157 + }); 158 + 159 + try { 160 + final pageAssets = await _assetPath!.getAssetListPaged( 161 + page: _currentPage, 162 + size: _pageSize, 163 + ); 164 + 165 + if (!mounted) return; 166 + setState(() { 167 + _assets.addAll(pageAssets); 168 + _currentPage += 1; 169 + _hasMore = pageAssets.length == _pageSize; 170 + _isLoading = false; 171 + }); 172 + } catch (e, stackTrace) { 173 + _logger.e( 174 + 'Failed to load next media page', 175 + error: e, 176 + stackTrace: stackTrace, 177 + ); 178 + if (!mounted) return; 179 + setState(() { 180 + _errorMessage = 'Unable to load more items.'; 181 + }); 182 + } finally { 183 + if (mounted) { 184 + setState(() { 185 + _isLoadingMore = false; 186 + }); 187 + } 188 + } 189 + } 190 + 191 + Future<void> _handleAssetTap(AssetEntity asset) async { 192 + if (_isMultiPhotoSelection) { 193 + _toggleMultiPhotoSelection(asset); 194 + return; 195 + } 196 + 197 + final file = await _assetToXFile(asset, showErrorMessage: true); 198 + if (file == null || !mounted) return; 199 + 200 + if (asset.type == AssetType.video) { 201 + Navigator.of(context).pop(SingleVideoSelection(file)); 202 + return; 203 + } 204 + 205 + Navigator.of(context).pop(SinglePhotoSelection(file)); 206 + } 207 + 208 + void _toggleMultiPhotoSelection(AssetEntity asset) { 209 + if (asset.type != AssetType.image) { 210 + _showSnackBar('You can only select photos in multi-select mode.'); 211 + return; 212 + } 213 + 214 + final selectedIndex = _selectedPhotoAssets.indexWhere( 215 + (element) => element.id == asset.id, 216 + ); 217 + 218 + if (selectedIndex >= 0) { 219 + setState(() { 220 + _selectedPhotoAssets.removeAt(selectedIndex); 221 + }); 222 + return; 223 + } 224 + 225 + if (_selectedPhotoAssets.length >= widget.maxMultiPhotoSelection) { 226 + _showSnackBar('You can select up to ${widget.maxMultiPhotoSelection}.'); 227 + return; 228 + } 229 + 230 + setState(() { 231 + _selectedPhotoAssets.add(asset); 232 + }); 233 + } 234 + 235 + Future<void> _submitMultiPhotoSelection() async { 236 + if (_selectedPhotoAssets.isEmpty) return; 237 + 238 + final files = <XFile>[]; 239 + for (final asset in _selectedPhotoAssets) { 240 + final file = await _assetToXFile(asset, showErrorMessage: false); 241 + if (file != null) { 242 + files.add(file); 243 + } 244 + } 245 + 246 + if (files.isEmpty) { 247 + _showSnackBar('Unable to access the selected photos.'); 248 + return; 249 + } 250 + 251 + if (!mounted) return; 252 + Navigator.of(context).pop(MultiPhotoSelection(files)); 253 + } 254 + 255 + Future<XFile?> _assetToXFile( 256 + AssetEntity asset, { 257 + required bool showErrorMessage, 258 + }) async { 259 + try { 260 + final file = await asset.file ?? await asset.originFile; 261 + if (file == null) { 262 + if (showErrorMessage) { 263 + _showSnackBar('Unable to access this media item.'); 264 + } 265 + return null; 266 + } 267 + 268 + return XFile(file.path); 269 + } catch (e, stackTrace) { 270 + _logger.e( 271 + 'Failed converting asset to file', 272 + error: e, 273 + stackTrace: stackTrace, 274 + ); 275 + if (showErrorMessage) { 276 + _showSnackBar('Unable to access this media item.'); 277 + } 278 + return null; 279 + } 280 + } 281 + 282 + void _toggleMultiSelectionMode() { 283 + setState(() { 284 + if (_isMultiPhotoSelection) { 285 + _selectedPhotoAssets.clear(); 286 + } 287 + _isMultiPhotoSelection = !_isMultiPhotoSelection; 288 + }); 289 + } 290 + 291 + void _showSnackBar(String message) { 292 + if (!mounted) return; 293 + ScaffoldMessenger.of( 294 + context, 295 + ).showSnackBar(SnackBar(content: Text(message))); 296 + } 297 + 298 + List<AssetEntity> get _mediaAssets { 299 + return _assets 300 + .where( 301 + (asset) => 302 + asset.type == AssetType.image || asset.type == AssetType.video, 303 + ) 304 + .toList(growable: false); 305 + } 306 + 307 + @override 308 + Widget build(BuildContext context) { 309 + final theme = Theme.of(context); 310 + final colorScheme = theme.colorScheme; 311 + final multiSelectLabel = _isMultiPhotoSelection 312 + ? 'Single Select' 313 + : 'Select multiple'; 314 + 315 + return ClipRRect( 316 + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), 317 + child: Material( 318 + color: colorScheme.surface, 319 + child: Column( 320 + children: [ 321 + const SizedBox(height: 8), 322 + Container( 323 + width: 40, 324 + height: 4, 325 + decoration: BoxDecoration( 326 + color: colorScheme.onSurface.withAlpha(60), 327 + borderRadius: BorderRadius.circular(999), 328 + ), 329 + ), 330 + Padding( 331 + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), 332 + child: Row( 333 + children: [ 334 + IconButton( 335 + onPressed: () => Navigator.of(context).maybePop(), 336 + icon: const Icon(Icons.close), 337 + ), 338 + const Expanded( 339 + child: Text( 340 + 'Library', 341 + textAlign: TextAlign.center, 342 + style: TextStyle( 343 + fontSize: 18, 344 + fontWeight: FontWeight.w600, 345 + ), 346 + ), 347 + ), 348 + const SizedBox(width: 48), 349 + ], 350 + ), 351 + ), 352 + if (widget.showMultiPhotoButton) 353 + Padding( 354 + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), 355 + child: Row( 356 + children: [ 357 + Expanded( 358 + child: OutlinedButton.icon( 359 + onPressed: _toggleMultiSelectionMode, 360 + icon: Icon( 361 + _isMultiPhotoSelection 362 + ? Icons.filter_1 363 + : Icons.collections_outlined, 364 + ), 365 + label: Text(multiSelectLabel), 366 + ), 367 + ), 368 + if (_isMultiPhotoSelection) ...[ 369 + const SizedBox(width: 12), 370 + FilledButton( 371 + onPressed: _selectedPhotoAssets.isEmpty 372 + ? null 373 + : _submitMultiPhotoSelection, 374 + child: Text( 375 + 'Done (${_selectedPhotoAssets.length}/${widget.maxMultiPhotoSelection})', 376 + ), 377 + ), 378 + ], 379 + ], 380 + ), 381 + ), 382 + if (_isLimitedPermission) 383 + Container( 384 + width: double.infinity, 385 + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), 386 + padding: const EdgeInsets.all(10), 387 + decoration: BoxDecoration( 388 + color: colorScheme.secondaryContainer, 389 + borderRadius: BorderRadius.circular(10), 390 + ), 391 + child: Text( 392 + 'Limited library access is enabled. ' 393 + 'You can change this in settings.', 394 + style: theme.textTheme.bodySmall?.copyWith( 395 + color: colorScheme.onSecondaryContainer, 396 + ), 397 + ), 398 + ), 399 + const Divider(height: 1), 400 + Expanded(child: _buildBody(context)), 401 + ], 402 + ), 403 + ), 404 + ); 405 + } 406 + 407 + Widget _buildBody(BuildContext context) { 408 + if (_isLoading) { 409 + return const Center(child: CircularProgressIndicator()); 410 + } 411 + 412 + if (!_hasPermission) { 413 + return Center( 414 + child: Padding( 415 + padding: const EdgeInsets.all(24), 416 + child: Column( 417 + mainAxisSize: MainAxisSize.min, 418 + children: [ 419 + const Icon(Icons.photo_library_outlined, size: 40), 420 + const SizedBox(height: 12), 421 + const Text( 422 + 'Allow photo library access to pick photos and videos.', 423 + textAlign: TextAlign.center, 424 + ), 425 + const SizedBox(height: 16), 426 + FilledButton( 427 + onPressed: _requestPermissionAndLoadAssets, 428 + child: const Text('Allow Access'), 429 + ), 430 + const SizedBox(height: 8), 431 + const TextButton( 432 + onPressed: PhotoManager.openSetting, 433 + child: Text('Open Settings'), 434 + ), 435 + ], 436 + ), 437 + ), 438 + ); 439 + } 440 + 441 + if (_errorMessage != null && _mediaAssets.isEmpty) { 442 + return Center( 443 + child: Padding( 444 + padding: const EdgeInsets.all(24), 445 + child: Text( 446 + _errorMessage!, 447 + textAlign: TextAlign.center, 448 + ), 449 + ), 450 + ); 451 + } 452 + 453 + if (_mediaAssets.isEmpty) { 454 + return const Center( 455 + child: Text('No photos or videos found in your library.'), 456 + ); 457 + } 458 + 459 + return GridView.builder( 460 + controller: _scrollController, 461 + padding: const EdgeInsets.all(2), 462 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 463 + crossAxisCount: 3, 464 + crossAxisSpacing: 2, 465 + mainAxisSpacing: 2, 466 + ), 467 + itemCount: _mediaAssets.length, 468 + itemBuilder: (context, index) { 469 + final asset = _mediaAssets[index]; 470 + final isVideo = asset.type == AssetType.video; 471 + final isDisabled = _isMultiPhotoSelection && isVideo; 472 + final selectedIndex = _selectedPhotoAssets.indexWhere( 473 + (element) => element.id == asset.id, 474 + ); 475 + final isSelected = selectedIndex >= 0; 476 + 477 + return GestureDetector( 478 + onTap: () => _handleAssetTap(asset), 479 + child: Stack( 480 + fit: StackFit.expand, 481 + children: [ 482 + _AssetThumbnail(asset: asset), 483 + if (isVideo) 484 + Positioned( 485 + left: 6, 486 + bottom: 6, 487 + child: Container( 488 + padding: const EdgeInsets.symmetric( 489 + horizontal: 6, 490 + vertical: 2, 491 + ), 492 + decoration: BoxDecoration( 493 + color: Colors.black.withAlpha(150), 494 + borderRadius: BorderRadius.circular(8), 495 + ), 496 + child: Text( 497 + _formatDuration(Duration(seconds: asset.duration)), 498 + style: const TextStyle( 499 + color: Colors.white, 500 + fontSize: 11, 501 + fontWeight: FontWeight.w600, 502 + ), 503 + ), 504 + ), 505 + ), 506 + if (_isMultiPhotoSelection) 507 + Positioned( 508 + top: 6, 509 + right: 6, 510 + child: AnimatedContainer( 511 + duration: const Duration(milliseconds: 120), 512 + width: 24, 513 + height: 24, 514 + decoration: BoxDecoration( 515 + shape: BoxShape.circle, 516 + color: isSelected 517 + ? Theme.of(context).colorScheme.primary 518 + : Colors.black.withAlpha(110), 519 + border: Border.all(color: Colors.white, width: 1.5), 520 + ), 521 + alignment: Alignment.center, 522 + child: Text( 523 + isSelected ? '${selectedIndex + 1}' : '', 524 + style: const TextStyle( 525 + color: Colors.white, 526 + fontSize: 11, 527 + fontWeight: FontWeight.w700, 528 + ), 529 + ), 530 + ), 531 + ), 532 + if (isDisabled) 533 + Container( 534 + color: Colors.black.withAlpha(140), 535 + alignment: Alignment.center, 536 + child: const Icon( 537 + Icons.block, 538 + color: Colors.white, 539 + size: 22, 540 + ), 541 + ), 542 + ], 543 + ), 544 + ); 545 + }, 546 + ); 547 + } 548 + 549 + String _formatDuration(Duration duration) { 550 + final hours = duration.inHours; 551 + final minutes = duration.inMinutes.remainder(60); 552 + final seconds = duration.inSeconds.remainder(60); 553 + 554 + String twoDigits(int value) => value.toString().padLeft(2, '0'); 555 + 556 + if (hours > 0) { 557 + return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}'; 558 + } 559 + return '${twoDigits(minutes)}:${twoDigits(seconds)}'; 560 + } 561 + } 562 + 563 + class _AssetThumbnail extends StatefulWidget { 564 + const _AssetThumbnail({required this.asset}); 565 + 566 + final AssetEntity asset; 567 + 568 + @override 569 + State<_AssetThumbnail> createState() => _AssetThumbnailState(); 570 + } 571 + 572 + class _AssetThumbnailState extends State<_AssetThumbnail> { 573 + late final Future<Uint8List?> _thumbnailFuture; 574 + 575 + @override 576 + void initState() { 577 + super.initState(); 578 + _thumbnailFuture = widget.asset.thumbnailDataWithSize( 579 + const ThumbnailSize.square(300), 580 + ); 581 + } 582 + 583 + @override 584 + Widget build(BuildContext context) { 585 + return FutureBuilder<Uint8List?>( 586 + future: _thumbnailFuture, 587 + builder: (context, snapshot) { 588 + final bytes = snapshot.data; 589 + if (bytes == null) { 590 + return ColoredBox( 591 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 592 + child: const SizedBox.expand(), 593 + ); 594 + } 595 + 596 + return Image.memory( 597 + bytes, 598 + fit: BoxFit.cover, 599 + gaplessPlayback: true, 600 + ); 601 + }, 602 + ); 603 + } 604 + }
+79
lib/src/features/posting/ui/pages/recording_page.dart
··· 13 13 import 'package:spark/src/core/utils/logging/logging.dart'; 14 14 import 'package:spark/src/features/posting/providers/camera_provider.dart'; 15 15 import 'package:spark/src/features/posting/providers/recording_provider.dart'; 16 + import 'package:spark/src/features/posting/ui/models/media_selection.dart'; 17 + import 'package:spark/src/features/posting/ui/pages/media_picker_page.dart'; 16 18 import 'package:spark/src/features/posting/utils/story_direct_post.dart'; 17 19 18 20 export 'package:spark/src/core/design_system/templates/recording_page_template.dart' ··· 121 123 } 122 124 123 125 await _processPhoto(photoFile); 126 + } 127 + 128 + Future<void> _openMediaLibraryPicker() async { 129 + if (_isProcessing) return; 130 + 131 + final recordingState = ref.read(recordingProvider); 132 + if (recordingState.isRecording) return; 133 + 134 + final selection = await showMediaLibraryPickerSheet( 135 + context, 136 + showMultiPhotoButton: !widget.storyMode, 137 + ); 138 + 139 + if (!mounted || selection == null) return; 140 + 141 + setState(() { 142 + _isProcessing = true; 143 + }); 144 + 145 + await _processLibrarySelection(selection); 146 + } 147 + 148 + Future<void> _processLibrarySelection(MediaLibrarySelection selection) async { 149 + switch (selection) { 150 + case SinglePhotoSelection(:final photo): 151 + await _processPhoto(photo); 152 + return; 153 + case SingleVideoSelection(:final video): 154 + await _processVideo(video); 155 + return; 156 + case MultiPhotoSelection(:final photos): 157 + await _processMultiPhotos(photos); 158 + return; 159 + } 160 + } 161 + 162 + Future<void> _processMultiPhotos(List<XFile> photos) async { 163 + if (photos.isEmpty) { 164 + if (!mounted) return; 165 + setState(() { 166 + _isProcessing = false; 167 + }); 168 + return; 169 + } 170 + 171 + try { 172 + await context.router.push( 173 + ImageReviewRoute( 174 + imageFiles: photos, 175 + storyMode: widget.storyMode, 176 + ), 177 + ); 178 + 179 + if (!mounted) return; 180 + 181 + setState(() { 182 + _isProcessing = false; 183 + }); 184 + await ref.read(cameraProvider.notifier).reinitializeCamera(); 185 + } catch (e, stackTrace) { 186 + _logger.e( 187 + 'Error processing multiple photos', 188 + error: e, 189 + stackTrace: stackTrace, 190 + ); 191 + if (mounted) { 192 + setState(() { 193 + _isProcessing = false; 194 + }); 195 + ScaffoldMessenger.of(context).showSnackBar( 196 + SnackBar(content: Text('Error: $e')), 197 + ); 198 + } 199 + } 124 200 } 125 201 126 202 Future<void> _processPhoto(XFile photoFile) async { ··· 435 511 onTap: _isProcessing ? null : _handleTap, 436 512 onRecordStart: _isProcessing ? null : _handleRecordStart, 437 513 onRecordStop: _isProcessing ? null : _handleRecordStop, 514 + onOpenLibrary: _isProcessing || recordingState.isRecording 515 + ? null 516 + : _openMediaLibraryPicker, 438 517 ), 439 518 if (cameraState.isFlipping) 440 519 const Positioned.fill(
+8
pubspec.lock
··· 1261 1261 url: "https://pub.dev" 1262 1262 source: hosted 1263 1263 version: "7.0.1" 1264 + photo_manager: 1265 + dependency: "direct main" 1266 + description: 1267 + name: photo_manager 1268 + sha256: "807688e3221e90fb02a4466746edd9cb9a0de025f8754c819f96604c00f6f1f5" 1269 + url: "https://pub.dev" 1270 + source: hosted 1271 + version: "3.8.3" 1264 1272 platform: 1265 1273 dependency: transitive 1266 1274 description:
+1
pubspec.yaml
··· 54 54 json_annotation: ^4.9.0 55 55 path: ^1.9.1 56 56 path_provider: ^2.1.2 57 + photo_manager: ^3.7.1 57 58 pool: ^1.5.0 58 59 posthog_flutter: ^5.11.1 59 60 pro_image_editor: ^12.0.0-beta.5
+2
widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift
··· 16 16 import fvp 17 17 import package_info_plus 18 18 import path_provider_foundation 19 + import photo_manager 19 20 import posthog_flutter 20 21 import pro_video_editor 21 22 import shared_preferences_foundation ··· 37 38 FvpPlugin.register(with: registry.registrar(forPlugin: "FvpPlugin")) 38 39 FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 39 40 PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 41 + PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin")) 40 42 PosthogFlutterPlugin.register(with: registry.registrar(forPlugin: "PosthogFlutterPlugin")) 41 43 ProVideoEditorPlugin.register(with: registry.registrar(forPlugin: "ProVideoEditorPlugin")) 42 44 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))