mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
2
fork

Configure Feed

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

feat: camera and gallery image selection for media attachments in composer

+151 -9
+2 -2
doc/roadmap.txt
··· 43 43 - [x] Immediate save on media add/remove 44 44 - [x] Save on app backgrounding 45 45 - [x] Cancel pending saves on dispose 46 - - [/] Image picker integration: 46 + - [x] Image picker integration: 47 47 - [x] Gallery selection (image_picker) 48 - - [ ] Camera capture 48 + - [x] Camera capture 49 49 - [x] Max 4 images limit 50 50 - [x] JPEG/PNG/WebP support 51 51 - [x] Add media to draft
+7 -1
ios/Runner/Info.plist
··· 14 14 <string>org.stormlightlabs.lazurite</string> 15 15 </array> 16 16 </dict> 17 - </array> 17 + 18 + <key>NSPhotoLibraryUsageDescription</key> 19 + <string>Lazurite needs access to your photo library to attach images to posts.</string> 20 + <key>NSCameraUsageDescription</key> 21 + <string>Lazurite needs access to your camera to take photos for posts.</string> 22 + <key>NSMicrophoneUsageDescription</key> 23 + <string>Lazurite needs access to your microphone for video recording.</string> 18 24 <key>CFBundleDevelopmentRegion</key> 19 25 <string>$(DEVELOPMENT_LANGUAGE)</string> 20 26 <key>CFBundleDisplayName</key>
+40 -4
lib/src/features/composer/presentation/screens/composer_screen.dart
··· 160 160 161 161 if (currentCount >= 4) return; 162 162 163 + // Show modal sheet to choose between Camera and Gallery 164 + final source = await showModalBottomSheet<ImageSource>( 165 + context: context, 166 + showDragHandle: true, 167 + builder: (context) => SafeArea( 168 + child: Column( 169 + mainAxisSize: MainAxisSize.min, 170 + children: [ 171 + ListTile( 172 + leading: const Icon(Icons.camera_alt_outlined), 173 + title: const Text('Take photo'), 174 + onTap: () => Navigator.pop(context, ImageSource.camera), 175 + ), 176 + ListTile( 177 + leading: const Icon(Icons.photo_library_outlined), 178 + title: const Text('Choose from gallery'), 179 + onTap: () => Navigator.pop(context, ImageSource.gallery), 180 + ), 181 + ], 182 + ), 183 + ), 184 + ); 185 + 186 + if (source == null) return; 187 + 188 + if (!mounted) return; 189 + 163 190 final picker = ImagePicker(); 164 - final images = await picker.pickMultiImage(limit: 4 - currentCount); 191 + final notifier = ref.read(composerProvider(widget.draftId).notifier); 165 192 166 - if (images.isNotEmpty) { 167 - final notifier = ref.read(composerProvider(widget.draftId).notifier); 168 - for (final image in images) { 193 + if (source == ImageSource.camera) { 194 + final image = await picker.pickImage(source: ImageSource.camera); 195 + if (image != null) { 169 196 final mimeType = _getMimeType(image.path); 170 197 await notifier.addMedia(image.path, mimeType); 198 + } 199 + } else { 200 + // Gallery allows multiple selection 201 + final images = await picker.pickMultiImage(limit: 4 - currentCount); 202 + if (images.isNotEmpty) { 203 + for (final image in images) { 204 + final mimeType = _getMimeType(image.path); 205 + await notifier.addMedia(image.path, mimeType); 206 + } 171 207 } 172 208 } 173 209 }
+2 -2
pubspec.lock
··· 601 601 source: hosted 602 602 version: "0.2.2+1" 603 603 image_picker_platform_interface: 604 - dependency: transitive 604 + dependency: "direct dev" 605 605 description: 606 606 name: image_picker_platform_interface 607 607 sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" ··· 849 849 source: hosted 850 850 version: "3.1.6" 851 851 plugin_platform_interface: 852 - dependency: transitive 852 + dependency: "direct dev" 853 853 description: 854 854 name: plugin_platform_interface 855 855 sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+2
pubspec.yaml
··· 82 82 mocktail: ^1.0.4 83 83 mocktail_image_network: ^1.2.0 84 84 fake_async: ^1.3.3 85 + image_picker_platform_interface: ^2.9.0 86 + plugin_platform_interface: ^2.1.0 85 87 86 88 flutter: 87 89 uses-material-design: true
+98
test/src/features/composer/presentation/screens/composer_screen_test.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:flutter_test/flutter_test.dart'; 4 4 import 'package:go_router/go_router.dart'; 5 + import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; 5 6 import 'package:lazurite/src/features/composer/application/composer_notifier.dart'; 6 7 import 'package:lazurite/src/features/composer/application/composer_providers.dart'; 7 8 import 'package:lazurite/src/features/composer/domain/draft.dart'; ··· 9 10 import 'package:lazurite/src/features/composer/presentation/screens/composer_screen.dart'; 10 11 import 'package:lazurite/src/features/composer/presentation/widgets/publish_button.dart'; 11 12 import 'package:mocktail/mocktail.dart'; 13 + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; 12 14 13 15 class MockDraftRepository extends Mock implements DraftRepository {} 14 16 15 17 void main() { 16 18 late MockDraftRepository mockRepository; 19 + late MockImagePickerPlatform mockImagePickerPlatform; 17 20 18 21 setUp(() { 19 22 mockRepository = MockDraftRepository(); 23 + mockImagePickerPlatform = MockImagePickerPlatform(); 24 + ImagePickerPlatform.instance = mockImagePickerPlatform; 25 + registerFallbackValue(FakeImagePickerOptions()); 26 + registerFallbackValue(FakeMultiImagePickerOptions()); 20 27 }); 21 28 22 29 Draft createMockDraft({ ··· 316 323 317 324 verify(() => notifier.mockForceSaveObj('Saving on pause')).called(1); 318 325 }); 326 + 327 + testWidgets('shows image source selection sheet when adding media', (tester) async { 328 + await tester.pumpWidget(buildTestWidget()); 329 + await tester.pumpAndSettle(); 330 + 331 + await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined)); 332 + await tester.pumpAndSettle(); 333 + 334 + expect(find.text('Take photo'), findsOneWidget); 335 + expect(find.text('Choose from gallery'), findsOneWidget); 336 + }); 337 + 338 + testWidgets('picks image from camera', (tester) async { 339 + final file = XFile('test_image.jpg'); 340 + when( 341 + () => mockImagePickerPlatform.getImageFromSource( 342 + source: ImageSource.camera, 343 + options: any(named: 'options'), 344 + ), 345 + ).thenAnswer((_) async => file); 346 + 347 + await tester.pumpWidget(buildTestWidget()); 348 + await tester.pumpAndSettle(); 349 + 350 + await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined)); 351 + await tester.pumpAndSettle(); 352 + 353 + await tester.tap(find.text('Take photo')); 354 + await tester.pumpAndSettle(); 355 + 356 + final element = tester.element(find.byType(ComposerScreen)); 357 + final container = ProviderScope.containerOf(element); 358 + final notifier = 359 + container.read(composerProvider(null).notifier) as MockComposerNotifierWrapper; 360 + 361 + verify(() => notifier.mockAddMediaObj('test_image.jpg', 'image/jpeg')).called(1); 362 + }); 363 + 364 + testWidgets('picks multiple images from gallery', (tester) async { 365 + final file1 = XFile('image1.png'); 366 + final file2 = XFile('image2.webp'); 367 + 368 + when( 369 + () => mockImagePickerPlatform.getMultiImageWithOptions(options: any(named: 'options')), 370 + ).thenAnswer((_) async => [file1, file2]); 371 + 372 + await tester.pumpWidget(buildTestWidget()); 373 + await tester.pumpAndSettle(); 374 + 375 + await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined)); 376 + await tester.pumpAndSettle(); 377 + 378 + await tester.tap(find.text('Choose from gallery')); 379 + await tester.pumpAndSettle(); 380 + 381 + final element = tester.element(find.byType(ComposerScreen)); 382 + final container = ProviderScope.containerOf(element); 383 + final notifier = 384 + container.read(composerProvider(null).notifier) as MockComposerNotifierWrapper; 385 + 386 + verify(() => notifier.mockAddMediaObj('image1.png', 'image/png')).called(1); 387 + verify(() => notifier.mockAddMediaObj('image2.webp', 'image/webp')).called(1); 388 + }); 319 389 }); 320 390 } 321 391 ··· 349 419 } 350 420 351 421 void mockForceSave(String text) {} 422 + 423 + @override 424 + Future<void> addMedia(String path, String mimeType) async { 425 + mockAddMedia(path, mimeType); 426 + } 427 + 428 + void mockAddMedia(String path, String mimeType) {} 352 429 } 353 430 354 431 class MockComposerNotifierWrapper extends _MockComposerNotifier { 355 432 MockComposerNotifierWrapper(super.draft); 356 433 357 434 final _mockForceSave = MockForceSave(); 435 + final _mockAddMedia = MockAddMedia(); 358 436 359 437 @override 360 438 void mockForceSave(String text) => _mockForceSave(text); 361 439 440 + @override 441 + void mockAddMedia(String path, String mimeType) => _mockAddMedia(path, mimeType); 442 + 362 443 MockForceSave get mockForceSaveObj => _mockForceSave; 444 + MockAddMedia get mockAddMediaObj => _mockAddMedia; 445 + } 446 + 447 + class MockAddMedia extends Mock implements AddMediaHandler {} 448 + 449 + abstract class AddMediaHandler { 450 + void call(String path, String mimeType); 363 451 } 364 452 365 453 abstract class ForceSaveHandler { ··· 367 455 } 368 456 369 457 class MockForceSave extends Mock implements ForceSaveHandler {} 458 + 459 + class MockImagePickerPlatform extends Mock 460 + with MockPlatformInterfaceMixin 461 + implements ImagePickerPlatform {} 462 + 463 + class MockImagePicker extends Mock implements ImagePickerPlatform {} 464 + 465 + class FakeImagePickerOptions extends Fake implements ImagePickerOptions {} 466 + 467 + class FakeMultiImagePickerOptions extends Fake implements MultiImagePickerOptions {}