[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.

imgly sdk cesdk idk idc tbh tmj mlk krl abc def ghi (#77)

* EU ODEIO JAVA EU ODEIO JAVA EU ODEIO JAVA

* dias de luta dias de gloria

* ta foda

* NO MORE GAMBIARRAS TIME TO LOCK IN

* agora sim

* uepa

* ULTRA CANETADA

* a cereja no bolo

* safearea

* ? 'ALT' : 'ALT'

* license on action

---------

Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

Jean Carlo Polo
daviirodrig
and committed by
GitHub
dcd5e581 fcafe87a

+754 -1521
+1
.github/workflows/android-internal-release.yml
··· 54 54 echo "VIDEO_SERVICE_URL=https://video.sprk.so" >> .env 55 55 echo "SPRK_APPVIEW_URL=https://api.sprk.so" >> .env 56 56 echo "MESSAGES_SERVICE_URL=https://chat.sprk.so" >> .env 57 + echo "SHOWCASES_LICENSE_FLUTTER=${{ secrets.SHOWCASES_LICENSE_FLUTTER }}" >> .env 57 58 echo "SIGNUPS_DISABLED=false" >> .env 58 59 59 60 - name: Set versionCode (commit count + run attempt)
+3
lib/src/core/config/app_config.dart
··· 10 10 /// Base URL for the video processing service. 11 11 static String get videoServiceUrl => _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); 12 12 13 + /// License key for the img.ly editor. 14 + static String get license => _getStringValue('SHOWCASES_LICENSE_FLUTTER', ''); 15 + 13 16 /// URL for the app view (web view display). 14 17 static String get appViewUrl => _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); 15 18
+4
lib/src/core/di/service_locator.dart
··· 2 2 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository_impl.dart'; 3 3 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 4 4 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 5 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 6 + import 'package:sparksocial/src/core/imgly/imgly_repository_impl.dart'; 5 7 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 7 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; ··· 30 32 Future<void> initServiceLocator() async { 31 33 // Register LogService 32 34 sl.registerSingleton<LogService>(LogService()); 35 + 36 + sl.registerLazySingleton<IMGLYRepository>(IMGLYRepositoryImpl.new); 33 37 34 38 // Register storage dependencies 35 39 // Initialize storage manager
+12
lib/src/core/imgly/imgly_repository.dart
··· 1 + import 'package:imgly_camera/imgly_camera.dart'; 2 + import 'package:imgly_editor/imgly_editor.dart'; 3 + 4 + abstract class IMGLYRepository { 5 + Future<CameraResult?> openCamera({String? userID, Map<String, dynamic>? metadata}); 6 + 7 + Future<CameraResult?> openCameraReaction({required String url, String? userID, Map<String, dynamic>? metadata}); 8 + 9 + Future<EditorResult?> openVideoEditor({String? handle, String? userID, Source? source, Map<String, dynamic>? metadata}); 10 + 11 + Future<EditorResult?> openImageEditor({String? userID, Source? source, Map<String, dynamic>? metadata}); 12 + }
+36
lib/src/core/imgly/imgly_repository_impl.dart
··· 1 + import 'package:imgly_camera/imgly_camera.dart'; 2 + import 'package:imgly_editor/imgly_editor.dart'; 3 + import 'package:sparksocial/src/core/config/app_config.dart'; 4 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 5 + 6 + /// Android UI: android\app\src\main\kotlin\so\sprk\app\MainActivity.kt 7 + /// iOS UI: ios\Runner\AppDelegate.swift 8 + class IMGLYRepositoryImpl implements IMGLYRepository { 9 + @override 10 + Future<CameraResult?> openCamera({String? userID, Map<String, dynamic>? metadata}) async { 11 + final settings = CameraSettings(license: AppConfig.license, userId: userID); 12 + 13 + return IMGLYCamera.openCamera(settings, metadata: metadata); 14 + } 15 + 16 + @override 17 + Future<CameraResult?> openCameraReaction({required String url, String? userID, Map<String, dynamic>? metadata}) async { 18 + final settings = CameraSettings(license: AppConfig.license, userId: userID); 19 + 20 + return IMGLYCamera.openCamera(settings, video: url, metadata: metadata); 21 + } 22 + 23 + @override 24 + Future<EditorResult?> openVideoEditor({String? handle, String? userID, Source? source, Map<String, dynamic>? metadata}) async { 25 + final settings = EditorSettings(license: AppConfig.license, userId: userID); 26 + 27 + return IMGLYEditor.openEditor(settings: settings, source: source, preset: EditorPreset.video, metadata: metadata); 28 + } 29 + 30 + @override 31 + Future<EditorResult?> openImageEditor({String? userID, Source? source, Map<String, dynamic>? metadata}) async { 32 + final settings = EditorSettings(license: AppConfig.license, userId: userID); 33 + 34 + return IMGLYEditor.openEditor(settings: settings, preset: EditorPreset.photo, source: source, metadata: metadata); 35 + } 36 + }
+3 -9
lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart
··· 16 16 _logger.v('GraphRepository initialized'); 17 17 } 18 18 final SprkRepository _client; 19 - late final SettingsRepository _settingsRepository; 20 19 final SparkLogger _logger = GetIt.instance<LogService>().getLogger('GraphRepository'); 21 20 22 21 @override ··· 109 108 _logger.e('Session DID not available for authenticated user'); 110 109 throw Exception('Session DID not available'); 111 110 } 112 - try { 113 - /// goofy late check to ensure settings repository is initialized 114 - // ignore: unnecessary_statements 115 - _settingsRepository; // goofy late check 116 - } catch (e) { 117 - _settingsRepository = GetIt.instance<SettingsRepository>(); 118 - } 111 + 112 + final settingsRepository = GetIt.instance<SettingsRepository>(); 119 113 120 - final followMode = await _settingsRepository.getFollowMode(); 114 + final followMode = await settingsRepository.getFollowMode(); 121 115 _logger.d('Using follow mode: $followMode'); 122 116 123 117 final collection = followMode == FollowMode.sprk ? NSID.parse('so.sprk.graph.follow') : NSID.parse('app.bsky.graph.follow');
+1 -4
lib/src/core/routing/app_router.dart
··· 2 2 import 'package:collection/collection.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:image_picker/image_picker.dart'; 5 + import 'package:imgly_editor/model/editor_result.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 7 import 'package:sparksocial/src/core/routing/pages.dart'; 7 8 import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 8 - import 'package:video_player/video_player.dart'; 9 9 10 10 part 'app_router.gr.dart'; 11 11 ··· 78 78 ], 79 79 ), 80 80 AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), 81 - AutoRoute(page: VideoPlaybackRoute.page, path: '/video-playback'), 82 81 AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), 83 - AutoRoute(page: StoryReviewRoute.page, path: '/story-review'), 84 - AutoRoute(page: CreateVideoRoute.page, path: '/create-video'), 85 82 AutoRoute(page: ImageReviewRoute.page, path: '/image-review'), 86 83 87 84 // Stories pages
-3
lib/src/core/routing/pages.dart
··· 13 13 export 'package:sparksocial/src/features/messages/ui/pages/chat_page.dart'; 14 14 export 'package:sparksocial/src/features/messages/ui/pages/messages_page.dart'; 15 15 export 'package:sparksocial/src/features/messages/ui/pages/new_chat_search_page.dart'; 16 - export 'package:sparksocial/src/features/posting/ui/pages/create_video_page.dart'; 17 16 export 'package:sparksocial/src/features/posting/ui/pages/image_review_page.dart'; 18 - export 'package:sparksocial/src/features/posting/ui/pages/story_review_page.dart'; 19 - export 'package:sparksocial/src/features/posting/ui/pages/video_playback_page.dart'; 20 17 export 'package:sparksocial/src/features/posting/ui/pages/video_review_page.dart'; 21 18 export 'package:sparksocial/src/features/profile/ui/pages/edit_profile_page.dart'; 22 19 export 'package:sparksocial/src/features/profile/ui/pages/profile_page.dart';
+6 -10
lib/src/core/widgets/alt_text_editor_dialog.dart
··· 2 2 3 3 import 'package:auto_route/auto_route.dart'; 4 4 import 'package:flutter/material.dart'; 5 - import 'package:image_picker/image_picker.dart'; 6 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 6 8 7 class AltTextEditorDialog extends StatefulWidget { 9 - const AltTextEditorDialog({required this.initialAltText, super.key, this.imageFile}); 10 - final XFile? imageFile; 8 + const AltTextEditorDialog({required this.initialAltText, required this.imageFile, super.key}); 9 + final String imageFile; 11 10 final String initialAltText; 12 11 13 12 @override ··· 49 48 child: Column( 50 49 mainAxisSize: MainAxisSize.min, 51 50 children: [ 52 - if (widget.imageFile != null) 53 - ClipRRect( 54 - borderRadius: BorderRadius.circular(12), 55 - child: Image.file(File(widget.imageFile!.path), width: 220, height: 220, fit: BoxFit.cover), 56 - ) 57 - else 58 - Text('Add alt text', style: TextStyle(color: textColor, fontSize: 16)), 51 + ClipRRect( 52 + borderRadius: BorderRadius.circular(12), 53 + child: Image.file(File(widget.imageFile), width: 220, height: 220, fit: BoxFit.cover), 54 + ), 59 55 const SizedBox(height: 20), 60 56 Container( 61 57 decoration: BoxDecoration(
+1 -1
lib/src/features/comments/ui/widgets/comment_input.dart
··· 269 269 onTap: () async { 270 270 final result = await showDialog<String>( 271 271 context: context, 272 - builder: (context) => AltTextEditorDialog(imageFile: imageFile, initialAltText: alt ?? ''), 272 + builder: (context) => AltTextEditorDialog(imageFile: imageFile.path, initialAltText: alt ?? ''), 273 273 ); 274 274 if (result != null) { 275 275 notifier.updateAltText(imageFile.path, result.trim());
-26
lib/src/features/feed/providers/delete_post.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 2 - import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 - import 'package:get_it/get_it.dart'; 4 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 - import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 - import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 7 - import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 8 - import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 9 - 10 - part 'delete_post.g.dart'; 11 - 12 - @riverpod 13 - Future<void> deletePost(Ref ref, AtUri postUri) async { 14 - try { 15 - // delete post from all feeds (UI onlt) 16 - for (final feed in ref.read(settingsProvider).feeds) { 17 - ref.read(feedNotifierProvider(feed).notifier).removePost(postUri); 18 - } 19 - // delete post from cache 20 - await GetIt.I<SQLCacheInterface>().deletePost(postUri); 21 - // delete post from network 22 - await GetIt.I<SprkRepository>().repo.deleteRecord(uri: postUri); 23 - } catch (e) { 24 - throw Exception('Failed to delete post: $e'); 25 - } 26 - }
-31
lib/src/features/feed/providers/feed_provider.dart
··· 585 585 } 586 586 } 587 587 588 - Future<void> removePost(AtUri uri) async { 589 - final currentIndex = state.index; 590 - final postIndex = state.loadedPosts.indexOf(uri); 591 - 592 - // Remove the post from the loaded posts list 593 - final updatedPosts = state.loadedPosts.where((e) => e != uri).toList(); 594 - 595 - // Adjust the index if necessary 596 - var newIndex = currentIndex; 597 - if (postIndex != -1) { 598 - if (postIndex < currentIndex) { 599 - // Post was deleted before current position, adjust index down 600 - newIndex = currentIndex - 1; 601 - } else if (postIndex == currentIndex && updatedPosts.isNotEmpty) { 602 - // Current post was deleted, stay at same index (which will show next post) 603 - // If we're at the end, move to the previous post 604 - if (newIndex >= updatedPosts.length) { 605 - newIndex = updatedPosts.length - 1; 606 - } 607 - } 608 - // Ensure index is within bounds 609 - newIndex = math.max(0, newIndex); 610 - if (updatedPosts.isNotEmpty) { 611 - newIndex = math.min(newIndex, updatedPosts.length - 1); 612 - } 613 - } 614 - 615 - _logger.d('Removing post $uri, adjusting index from $currentIndex to $newIndex'); 616 - state = state.copyWith(loadedPosts: updatedPosts, index: newIndex); 617 - } 618 - 619 588 /// Checks if a post should be hidden based on its labels and user preferences 620 589 Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 621 590 final hideAdultContent = await _settingsRepository.getHideAdultContent();
+15 -8
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 64 64 // } catch (e) { 65 65 // what 66 66 // } 67 - final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 67 + const maxRetries = 3; 68 + const delay = Duration(seconds: 2); 69 + for (final i = 0; i < maxRetries; i) { 70 + final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 68 71 69 - if (networkPost.isEmpty) { 70 - throw Exception('Post not found'); 72 + if (networkPost.isNotEmpty) { 73 + // Cache the post for future use 74 + await sqlCache.cachePost(networkPost.first); 75 + return networkPost.first; 76 + } 77 + 78 + // If post not found, wait and retry 79 + if (i < maxRetries - 1) { 80 + await Future.delayed(delay); 81 + } 71 82 } 72 - 73 - // Cache the post for future use 74 - await sqlCache.cachePost(networkPost.first); 75 - 76 - return networkPost.first; 83 + throw Exception('Failed to load post after $maxRetries attempts'); 77 84 } 78 85 } 79 86
+12 -2
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 7 7 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 + import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 10 11 import 'package:sparksocial/src/core/widgets/menu_action_button.dart'; 11 12 import 'package:sparksocial/src/core/widgets/report_dialog.dart'; 12 - import 'package:sparksocial/src/features/feed/providers/delete_post.dart'; 13 + import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 13 14 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 14 15 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/comment_action_button.dart'; 15 16 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/like_action_button.dart'; 16 17 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/profile_action_button.dart'; 17 18 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/share_action_button.dart'; 19 + import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 18 20 19 21 class SideActionBar extends ConsumerStatefulWidget { 20 22 const SideActionBar({ ··· 224 226 225 227 try { 226 228 final currentPost = _currentPost ?? widget.post; 227 - ref.read(deletePostProvider(AtUri.parse(currentPost.uri.toString()))); 229 + await GetIt.I<SQLCacheInterface>().deletePost(currentPost.uri); 230 + await GetIt.I<SprkRepository>().repo.deleteRecord(uri: currentPost.uri); 231 + final feeds = await GetIt.I<SettingsRepository>().getFeeds(); 232 + for (final feed in feeds) { 233 + ref.invalidate(feedNotifierProvider(feed)); 234 + } 235 + final did = currentPost.author.did; 236 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), true)); 237 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), false)); 228 238 messenger.showSnackBar(const SnackBar(content: Text('Post deleted successfully!'))); 229 239 if (context.mounted) { 230 240 context.router.popUntilRoot();
+87 -3
lib/src/features/home/ui/pages/main_page.dart
··· 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 - 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:image_picker/image_picker.dart'; 7 + import 'package:imgly_editor/imgly_editor.dart'; 8 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 6 9 import 'package:sparksocial/src/core/routing/app_router.dart'; 7 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 11 + import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 8 12 import 'package:sparksocial/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 9 13 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 10 14 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; ··· 23 27 super.initState(); 24 28 } 25 29 30 + void _showCreateMenu(BuildContext context) { 31 + final colorScheme = Theme.of(context).colorScheme; 32 + final imglyRepository = GetIt.I<IMGLYRepository>(); 33 + final handle = ref.read(sessionProvider)?.handle; 34 + showModalBottomSheet( 35 + context: context, 36 + backgroundColor: Colors.transparent, 37 + builder: (BuildContext context) { 38 + return SafeArea( 39 + child: Container( 40 + padding: const EdgeInsets.all(16), 41 + decoration: BoxDecoration( 42 + color: colorScheme.surface, 43 + borderRadius: const BorderRadius.only( 44 + topLeft: Radius.circular(20), 45 + topRight: Radius.circular(20), 46 + ), 47 + ), 48 + child: Wrap( 49 + children: <Widget>[ 50 + ListTile( 51 + leading: Icon(Icons.camera_alt, color: colorScheme.onSurface), 52 + title: Text('Record', style: TextStyle(color: colorScheme.onSurface)), 53 + onTap: () async { 54 + // camera -> open editor -> video review page -> post page 55 + final cameraResult = await imglyRepository.openCamera(userID: handle); 56 + if (cameraResult != null && cameraResult.recording != null && cameraResult.recording!.recordings.isNotEmpty) { 57 + if (context.mounted) { 58 + final video = await imglyRepository.openVideoEditor( 59 + source: Source.fromVideo(cameraResult.recording!.recordings.first.videos.first.uri), 60 + ); 61 + if (video != null && context.mounted) { 62 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: false)); 63 + } 64 + } 65 + } 66 + }, 67 + ), 68 + ListTile( 69 + leading: Icon(Icons.videocam, color: colorScheme.onSurface), 70 + title: Text('Upload Video', style: TextStyle(color: colorScheme.onSurface)), 71 + onTap: () async { 72 + // pick video -> open editor -> video review page -> post page 73 + final pickedVideo = await ImagePicker().pickVideo( 74 + source: ImageSource.gallery, 75 + maxDuration: const Duration(seconds: 180), 76 + ); 77 + if (pickedVideo != null && context.mounted) { 78 + final video = await imglyRepository.openVideoEditor( 79 + source: Source.fromVideo('file://${pickedVideo.path}'), 80 + ); 81 + if (video != null && context.mounted) { 82 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: false)); 83 + } 84 + } 85 + }, 86 + ), 87 + ListTile( 88 + leading: Icon(Icons.photo_library, color: colorScheme.onSurface), 89 + title: Text('Upload Images', style: TextStyle(color: colorScheme.onSurface)), 90 + onTap: () async { 91 + // pick images -> images review page (image editor when image is selected) -> post page 92 + final pickedImages = await ImagePicker().pickMultiImage(limit: 12); 93 + if (context.mounted && pickedImages.isNotEmpty) { 94 + context.router.push( 95 + ImageReviewRoute( 96 + imageFiles: pickedImages, 97 + storyMode: false, 98 + ), 99 + ); 100 + } 101 + }, 102 + ), 103 + ], 104 + ), 105 + ), 106 + ); 107 + }, 108 + ); 109 + } 110 + 26 111 @override 27 112 Widget build(BuildContext context) { 28 113 return AutoTabsRouter( ··· 39 124 selectedIndex: tabsRouter.activeIndex, 40 125 onDestinationSelected: (index) { 41 126 if (index == 2) { 42 - // Special case for Create button - navigate to create video page 43 - context.router.push(CreateVideoRoute()); 127 + _showCreateMenu(context); 44 128 } else { 45 129 if (tabsRouter.activeIndex == index && index == 0) { 46 130 final activeFeed = ref.read(settingsProvider).activeFeed;
+2 -2
lib/src/features/posting/providers/post_story.dart
··· 7 7 part 'post_story.g.dart'; 8 8 9 9 @riverpod 10 - FutureOr<void> postStory(Ref ref, Embed embed, {List<SelfLabel>? selfLabels, List<String>? tags}) async { 10 + FutureOr<StrongRef?> postStory(Ref ref, Embed embed, {List<SelfLabel>? selfLabels, List<String>? tags}) async { 11 11 final feedRepository = GetIt.I<SprkRepository>().feed; 12 - await feedRepository.postStory(embed, selfLabels: selfLabels, tags: tags); 12 + return await feedRepository.postStory(embed, selfLabels: selfLabels, tags: tags); 13 13 }
-96
lib/src/features/posting/providers/upload_provider.dart
··· 1 - import 'dart:async'; 2 - 3 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 - 5 - import 'package:sparksocial/src/features/posting/providers/upload_state.dart'; 6 - 7 - part 'upload_provider.g.dart'; 8 - 9 - @riverpod 10 - class Upload extends _$Upload { 11 - Timer? _completedTasksTimer; 12 - 13 - @override 14 - UploadState build() { 15 - ref.onDispose(() { 16 - _completedTasksTimer?.cancel(); 17 - }); 18 - 19 - return const UploadState(); 20 - } 21 - 22 - // Register a new upload task 23 - String registerTask(String type) { 24 - final id = DateTime.now().millisecondsSinceEpoch.toString(); 25 - final newTask = UploadTask(id: id, type: type); 26 - 27 - state = state.copyWith(tasks: {...state.tasks, id: newTask}); 28 - 29 - return id; 30 - } 31 - 32 - // Start an upload task 33 - void startTask(String id) { 34 - if (state.tasks.containsKey(id)) { 35 - final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.uploading); 36 - 37 - state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 38 - 39 - _updateActiveStatus(); 40 - } 41 - } 42 - 43 - // Complete an upload task 44 - void completeTask(String id) { 45 - if (state.tasks.containsKey(id)) { 46 - final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.completed); 47 - 48 - state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 49 - 50 - _updateActiveStatus(); 51 - _updateCompletedStatus(); 52 - _setupCompletedTasksCleanup(); 53 - } 54 - } 55 - 56 - // Mark a task as failed 57 - void failTask(String id, String errorMessage) { 58 - if (state.tasks.containsKey(id)) { 59 - final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.error, errorMessage: errorMessage); 60 - 61 - state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 62 - 63 - _updateActiveStatus(); 64 - } 65 - } 66 - 67 - // Clear all completed tasks 68 - void clearCompletedTasks() { 69 - final filteredTasks = Map<String, UploadTask>.fromEntries( 70 - state.tasks.entries.where((entry) => entry.value.status != UploadStatus.completed), 71 - ); 72 - 73 - state = state.copyWith(tasks: filteredTasks); 74 - _updateCompletedStatus(); 75 - } 76 - 77 - void _updateActiveStatus() { 78 - final isAnyTaskActive = state.tasks.values.any((task) => task.status == UploadStatus.uploading); 79 - 80 - state = state.copyWith(isAnyTaskActive: isAnyTaskActive); 81 - } 82 - 83 - void _updateCompletedStatus() { 84 - final isAnyTaskCompleted = state.tasks.values.any((task) => task.status == UploadStatus.completed); 85 - 86 - state = state.copyWith(isAnyTaskCompleted: isAnyTaskCompleted); 87 - } 88 - 89 - void _setupCompletedTasksCleanup() { 90 - // Cancel existing timer if there is one 91 - _completedTasksTimer?.cancel(); 92 - 93 - // Set up new timer to clear completed tasks after 3 seconds 94 - _completedTasksTimer = Timer(const Duration(seconds: 3), clearCompletedTasks); 95 - } 96 - }
-24
lib/src/features/posting/providers/upload_state.dart
··· 1 - import 'package:freezed_annotation/freezed_annotation.dart'; 2 - 3 - part 'upload_state.freezed.dart'; 4 - 5 - enum UploadStatus { idle, uploading, completed, error } 6 - 7 - @freezed 8 - class UploadTask with _$UploadTask { 9 - const factory UploadTask({ 10 - required String id, 11 - required String type, 12 - @Default(UploadStatus.idle) UploadStatus status, 13 - String? errorMessage, 14 - }) = _UploadTask; 15 - } 16 - 17 - @freezed 18 - class UploadState with _$UploadState { 19 - const factory UploadState({ 20 - @Default({}) Map<String, UploadTask> tasks, 21 - @Default(false) bool isAnyTaskActive, 22 - @Default(false) bool isAnyTaskCompleted, 23 - }) = _UploadState; 24 - }
+121 -118
lib/src/features/posting/providers/video_upload_provider.dart
··· 1 + import 'package:atproto/atproto.dart'; 1 2 import 'package:atproto/core.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 4 import 'package:get_it/get_it.dart'; 3 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 6 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 5 7 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 8 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 - import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 - import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 9 + import 'package:sparksocial/src/features/posting/providers/post_story.dart'; 9 10 10 11 part 'video_upload_provider.g.dart'; 11 12 13 + /// Process a video file and upload it to the video service 12 14 @riverpod 13 - class VideoUpload extends _$VideoUpload { 14 - late final AuthRepository _authRepository; 15 - late final FeedRepository _feedRepository; 16 - late final SparkLogger _logger; 17 - 18 - @override 19 - VideoUploadState build(String videoPath) { 20 - _authRepository = GetIt.instance<SprkRepository>().authRepository; 21 - _feedRepository = GetIt.instance<SprkRepository>().feed; 22 - _logger = GetIt.instance<LogService>().getLogger('VideoService'); 23 - return VideoUploadState.initial(videoPath: videoPath); 24 - } 15 + Future<Blob?> processVideo(Ref ref, String videoPath) async { 16 + final feedRepository = GetIt.I<SprkRepository>().feed; 17 + final logger = GetIt.I<LogService>().getLogger('Processing Video'); 18 + try { 19 + logger.i('Starting video processing for: $videoPath'); 25 20 26 - /// Process a video file and upload it to the video service 27 - Future<void> processVideo(String videoPath) async { 28 - try { 29 - state = VideoUploadState.processingVideo(videoPath: videoPath); 30 - _logger.i('Starting video processing for: $videoPath'); 21 + final blob = await feedRepository.uploadVideo(videoPath); 31 22 32 - final blob = await _feedRepository.uploadVideo(videoPath); 23 + logger.i('Video processed successfully'); 24 + return blob; 25 + } catch (error, stackTrace) { 26 + logger.e('Error processing video', error: error, stackTrace: stackTrace); 27 + return null; 28 + } 29 + } 33 30 34 - state = VideoUploadState.videoProcessed(videoPath: videoPath, blob: blob); 31 + /// Post a video to the feed using the processed blob reference 32 + @riverpod 33 + Future<StrongRef?> postVideo( 34 + Ref ref, { 35 + required Blob blob, 36 + String description = '', 37 + String altText = '', 38 + String? videoPath, 39 + bool crosspostToBsky = false, 40 + }) async { 41 + final logger = GetIt.I<LogService>().getLogger('Posting Video'); 42 + try { 43 + final authRepository = GetIt.I<AuthRepository>(); 35 44 36 - _logger.i('Video processed successfully'); 37 - } catch (error, stackTrace) { 38 - _logger.e('Error processing video', error: error, stackTrace: stackTrace); 39 - state = VideoUploadState.error(message: error.toString(), videoPath: videoPath); 45 + final authAtProto = authRepository.atproto; 46 + if (authAtProto == null || authAtProto.session == null) { 47 + throw Exception('AtProto not initialized'); 40 48 } 41 - } 42 49 43 - /// Post a video to the feed using the processed blob reference 44 - Future<void> postVideo({ 45 - required Blob blob, 46 - String description = '', 47 - String altText = '', 48 - String? videoPath, 49 - bool crosspostToBsky = false, 50 - }) async { 51 - try { 52 - state = VideoUploadState.postingVideo( 53 - videoPath: videoPath ?? state.currentVideoPath ?? '', 54 - blob: blob, 55 - description: description, 56 - altText: altText, 57 - ); 58 - 59 - final authAtProto = _authRepository.atproto; 60 - if (authAtProto == null || authAtProto.session == null) { 61 - throw Exception('AtProto not initialized'); 62 - } 50 + final postText = description.isNotEmpty ? description : ''; 63 51 64 - final postText = description.isNotEmpty ? description : ''; 52 + // Create a properly formatted AT Protocol post record 53 + final postRecord = PostRecord( 54 + text: postText, 55 + embed: EmbedVideo(video: blob, alt: altText), 56 + createdAt: DateTime.now().toUtc(), 57 + ); 65 58 66 - // Create a properly formatted AT Protocol post record 67 - final postRecord = PostRecord( 68 - text: postText, 69 - embed: EmbedVideo(video: blob, alt: altText), 70 - createdAt: DateTime.now().toUtc(), 71 - ); 59 + // Create the post record 60 + final recordRes = await authAtProto.repo.createRecord( 61 + collection: NSID.parse('so.sprk.feed.post'), 62 + record: postRecord.toJson(), 63 + ); 72 64 73 - // Create the post record 74 - final recordRes = await authAtProto.repo.createRecord( 75 - collection: NSID.parse('so.sprk.feed.post'), 76 - record: postRecord.toJson(), 77 - ); 65 + if (recordRes.status != HttpStatus.ok) { 66 + throw Exception('Failed to post video: ${recordRes.status} ${recordRes.data}'); 67 + } 78 68 79 - if (recordRes.status != HttpStatus.ok) { 80 - throw Exception('Failed to post video: ${recordRes.status} ${recordRes.data}'); 69 + // Crosspost to Bluesky if enabled 70 + if (crosspostToBsky) { 71 + try { 72 + await _crosspostVideoToBlueSky(ref, postText, blob, altText, recordRes.data.uri.rkey); 73 + } catch (e) { 74 + logger.w('Failed to crosspost video to Bluesky: $e'); 75 + // Don't fail the entire operation if Bluesky crossposting fails 81 76 } 82 - 83 - // Crosspost to Bluesky if enabled 84 - if (crosspostToBsky) { 85 - try { 86 - await _crosspostVideoToBlueSky(postText, blob, altText, recordRes.data.uri.rkey); 87 - } catch (e) { 88 - _logger.w('Failed to crosspost video to Bluesky: $e'); 89 - // Don't fail the entire operation if Bluesky crossposting fails 90 - } 91 - } 92 - 93 - state = VideoUploadState.posted(videoPath: videoPath ?? state.currentVideoPath ?? '', blob: blob, postRef: recordRes.data); 94 - 95 - _logger.i('Video posted successfully'); 96 - } catch (error, stackTrace) { 97 - _logger.e('Error posting video', error: error, stackTrace: stackTrace); 98 - state = VideoUploadState.error(message: error.toString(), videoPath: videoPath ?? state.currentVideoPath, blob: blob); 99 77 } 78 + logger.i('Video posted successfully'); 79 + return recordRes.data; 80 + } catch (error, stackTrace) { 81 + logger.e('Error posting video', error: error, stackTrace: stackTrace); 100 82 } 83 + return null; 84 + } 101 85 102 - /// Process video and post it in one step 103 - Future<void> processAndPostVideo({ 104 - required String videoPath, 105 - String description = '', 106 - String altText = '', 107 - bool crosspostToBsky = false, 108 - }) async { 109 - await processVideo(videoPath); 86 + /// Process video and post it in one step 87 + @riverpod 88 + Future<StrongRef?> processAndPostVideo( 89 + Ref ref, { 90 + required String videoPath, 91 + String description = '', 92 + String altText = '', 93 + bool crosspostToBsky = false, 94 + bool storyMode = false, 95 + }) async { 96 + final blob = await processVideo(ref, videoPath); 97 + if (blob == null) { 98 + throw Exception('Failed to process video'); 99 + } 110 100 111 - // Check if processing was successful 112 - final currentState = state; 113 - if (currentState is VideoUploadStateVideoProcessed) { 114 - await postVideo( 115 - blob: currentState.blob, 116 - description: description, 117 - altText: altText, 118 - videoPath: videoPath, 119 - crosspostToBsky: crosspostToBsky, 120 - ); 121 - } 101 + if (storyMode) { 102 + // Post as a story 103 + return ref 104 + .read( 105 + postStoryProvider( 106 + EmbedVideo(video: blob), 107 + selfLabels: [], 108 + tags: [], 109 + ), 110 + ) 111 + .value; 112 + } else { 113 + // Post as a regular video 114 + return postVideo( 115 + ref, 116 + blob: blob, 117 + description: description, 118 + altText: altText, 119 + videoPath: videoPath, 120 + crosspostToBsky: crosspostToBsky, 121 + ); 122 122 } 123 + } 123 124 124 - /// Crosspost video to Bluesky using same blob but Bluesky models 125 - Future<void> _crosspostVideoToBlueSky(String text, Blob blob, String altText, String rkey) async { 126 - _logger.d('Crossposting video to Bluesky'); 125 + /// Crosspost video to Bluesky using same blob but Bluesky models 126 + @riverpod 127 + Future<void> _crosspostVideoToBlueSky(Ref ref, String text, Blob blob, String altText, String rkey) async { 128 + final logger = GetIt.I<LogService>().getLogger('Crossposting Video to Bluesky'); 129 + final authRepository = GetIt.I<AuthRepository>(); 130 + logger.d('Crossposting video to Bluesky'); 127 131 128 - final session = _authRepository.session; 129 - if (session == null) { 130 - throw Exception('No session available for Bluesky crosspost'); 131 - } 132 + final session = authRepository.session; 133 + if (session == null) { 134 + throw Exception('No session available for Bluesky crosspost'); 135 + } 132 136 133 - // Create Bluesky video post record using direct JSON structure 134 - final bskyPostRecord = <String, dynamic>{ 135 - r'$type': 'app.bsky.feed.post', 136 - 'text': text, 137 - 'embed': {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': altText}, 138 - 'createdAt': DateTime.now().toUtc().toIso8601String(), 139 - }; 137 + // Create Bluesky video post record using direct JSON structure 138 + final bskyPostRecord = <String, dynamic>{ 139 + r'$type': 'app.bsky.feed.post', 140 + 'text': text, 141 + 'embed': {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': altText}, 142 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 143 + }; 140 144 141 - final bskyAtProto = _authRepository.atproto!; 142 - final bskyResult = await bskyAtProto.repo.createRecord( 143 - collection: NSID.parse('app.bsky.feed.post'), 144 - record: bskyPostRecord, 145 - rkey: rkey, 146 - ); 145 + final bskyAtProto = authRepository.atproto!; 146 + final bskyResult = await bskyAtProto.repo.createRecord( 147 + collection: NSID.parse('app.bsky.feed.post'), 148 + record: bskyPostRecord, 149 + rkey: rkey, 150 + ); 147 151 148 - _logger.i('Successfully crossposted video to Bluesky: ${bskyResult.data.uri}'); 149 - } 152 + logger.i('Successfully crossposted video to Bluesky: ${bskyResult.data.uri}'); 150 153 }
-74
lib/src/features/posting/providers/video_upload_state.dart
··· 1 - import 'package:atproto/atproto.dart'; 2 - import 'package:atproto_core/atproto_core.dart'; 3 - import 'package:freezed_annotation/freezed_annotation.dart'; 4 - 5 - part 'video_upload_state.freezed.dart'; 6 - 7 - @freezed 8 - class VideoUploadState with _$VideoUploadState { 9 - const VideoUploadState._(); 10 - 11 - /// Initial state 12 - const factory VideoUploadState.initial({required String videoPath}) = VideoUploadStateInitial; 13 - 14 - /// Processing video file 15 - const factory VideoUploadState.processingVideo({required String videoPath}) = VideoUploadStateProcessingVideo; 16 - 17 - /// Video processed successfully 18 - const factory VideoUploadState.videoProcessed({required String videoPath, required Blob blob}) = VideoUploadStateVideoProcessed; 19 - 20 - /// Posting video to feed 21 - const factory VideoUploadState.postingVideo({ 22 - required String videoPath, 23 - required Blob blob, 24 - required String description, 25 - required String altText, 26 - }) = VideoUploadStatePostingVideo; 27 - 28 - /// Video posted successfully 29 - const factory VideoUploadState.posted({required String videoPath, required Blob blob, required StrongRef postRef}) = 30 - VideoUploadStatePosted; 31 - 32 - /// Error occurred 33 - const factory VideoUploadState.error({required String message, String? videoPath, Blob? blob}) = VideoUploadStateError; 34 - 35 - /// Whether the service is currently busy 36 - bool get isBusy => when( 37 - initial: (_) => false, 38 - processingVideo: (_) => true, 39 - videoProcessed: (_, _) => false, 40 - postingVideo: (_, _, _, _) => true, 41 - posted: (_, _, _) => false, 42 - error: (_, _, _) => false, 43 - ); 44 - 45 - /// Whether there's an error 46 - bool get hasError => when( 47 - initial: (_) => false, 48 - processingVideo: (_) => false, 49 - videoProcessed: (_, _) => false, 50 - postingVideo: (_, _, _, _) => false, 51 - posted: (_, _, _) => false, 52 - error: (_, _, _) => true, 53 - ); 54 - 55 - /// Get current video path if available 56 - String? get currentVideoPath => when( 57 - initial: (path) => path, 58 - processingVideo: (path) => path, 59 - videoProcessed: (path, _) => path, 60 - postingVideo: (path, _, _, _) => path, 61 - posted: (path, _, _) => path, 62 - error: (_, path, _) => path, 63 - ); 64 - 65 - /// Get current blob reference if available 66 - Blob? get currentBlob => when( 67 - initial: (_) => null, 68 - processingVideo: (_) => null, 69 - videoProcessed: (_, blob) => blob, 70 - postingVideo: (_, blob, _, _) => blob, 71 - posted: (_, blob, _) => blob, 72 - error: (_, _, blob) => blob, 73 - ); 74 - }
-263
lib/src/features/posting/ui/pages/create_video_page.dart
··· 1 - import 'dart:async'; 2 - 3 - import 'package:auto_route/auto_route.dart'; 4 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 - import 'package:flutter/material.dart'; 6 - import 'package:flutter/services.dart'; 7 - import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 - import 'package:image_picker/image_picker.dart'; 9 - import 'package:sparksocial/src/core/routing/app_router.dart'; 10 - import 'package:sparksocial/src/features/posting/providers/camera_provider.dart'; 11 - import 'package:sparksocial/src/features/posting/ui/widgets/camera_controls.dart'; 12 - import 'package:sparksocial/src/features/posting/ui/widgets/camera_view.dart'; 13 - import 'package:sparksocial/src/features/posting/ui/widgets/mode_selector.dart'; 14 - import 'package:sparksocial/src/features/posting/ui/widgets/permission_requrest.dart'; 15 - import 'package:sparksocial/src/features/posting/ui/widgets/recording_bar.dart'; 16 - 17 - @RoutePage() 18 - class CreateVideoPage extends ConsumerStatefulWidget { 19 - const CreateVideoPage({super.key, this.isStoryMode = false}); 20 - final bool isStoryMode; 21 - 22 - @override 23 - ConsumerState<CreateVideoPage> createState() => _CreateVideoPageState(); 24 - } 25 - 26 - class _CreateVideoPageState extends ConsumerState<CreateVideoPage> with WidgetsBindingObserver { 27 - bool _isVideoMode = true; 28 - bool _isRecording = false; 29 - double _recordingProgress = 0; 30 - String _recordingTimeText = '00:00 / 03:00'; 31 - Timer? _recordingTimer; 32 - int _recordingSeconds = 0; 33 - final int _maxRecordingSeconds = 180; // 3 minutes 34 - final ImagePicker _picker = ImagePicker(); 35 - final bool _cameraPermissionDenied = false; 36 - 37 - @override 38 - void initState() { 39 - super.initState(); 40 - WidgetsBinding.instance.addObserver(this); 41 - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); 42 - Future.delayed(const Duration(milliseconds: 100), () { 43 - if (mounted) { 44 - ref.read(cameraProvider.notifier); 45 - } 46 - }); 47 - } 48 - 49 - @override 50 - void dispose() { 51 - WidgetsBinding.instance.removeObserver(this); 52 - _stopRecordingTimer(); 53 - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); 54 - super.dispose(); 55 - } 56 - 57 - Future<void> _onVideoGalleryPressed() async { 58 - try { 59 - final video = await _picker.pickVideo(source: ImageSource.gallery, maxDuration: const Duration(seconds: 180)); 60 - 61 - if (video != null && mounted) { 62 - if (widget.isStoryMode) { 63 - context.router.push(StoryReviewRoute(videoPath: video.path, imageFile: XFile(''))); 64 - } else { 65 - context.router.push(VideoReviewRoute(videoPath: video.path)); 66 - } 67 - } 68 - } catch (e) { 69 - if (mounted) { 70 - showDialog( 71 - context: context, 72 - builder: (BuildContext context) { 73 - return AlertDialog( 74 - title: const Text('Error'), 75 - content: Text('Failed to select video: $e'), 76 - actions: [TextButton(onPressed: () => context.router.maybePop(), child: const Text('OK'))], 77 - ); 78 - }, 79 - ); 80 - } 81 - } 82 - } 83 - 84 - Future<void> _onImageGalleryPressed() async { 85 - try { 86 - if (widget.isStoryMode) { 87 - // For stories, only allow one image 88 - final image = await _picker.pickImage(source: ImageSource.gallery); 89 - if (image != null && mounted) { 90 - context.router.push(StoryReviewRoute(videoPath: '', imageFile: image)); 91 - } 92 - } else { 93 - // For regular posts, allow multiple images 94 - const maxImages = 12; 95 - final pickedFiles = await _picker.pickMultiImage(limit: maxImages); 96 - if (pickedFiles.isEmpty) return; 97 - final limitedFiles = pickedFiles.length > maxImages ? pickedFiles.sublist(0, maxImages) : pickedFiles; 98 - if (!mounted) return; 99 - context.router.push(ImageReviewRoute(imageFiles: limitedFiles)); 100 - } 101 - } catch (e) { 102 - if (!mounted) return; 103 - showDialog( 104 - context: context, 105 - builder: (BuildContext context) { 106 - return AlertDialog( 107 - title: const Text('Error'), 108 - content: Text('Failed to select images: $e'), 109 - actions: [TextButton(onPressed: () => context.router.maybePop(), child: const Text('OK'))], 110 - ); 111 - }, 112 - ); 113 - } 114 - } 115 - 116 - Future<void> _onCapturePressed() async { 117 - if (!_isVideoMode) { 118 - await _takePhoto(); 119 - } else { 120 - await _toggleVideoRecording(); 121 - } 122 - } 123 - 124 - Future<void> _takePhoto() async { 125 - final photo = await ref.read(cameraProvider.notifier).takePhoto(); 126 - if (photo != null) { 127 - if (widget.isStoryMode) { 128 - if (mounted) { 129 - context.router.push(StoryReviewRoute(videoPath: '', imageFile: photo)); 130 - } 131 - } 132 - } 133 - } 134 - 135 - Future<void> _toggleVideoRecording() async { 136 - if (_isRecording) { 137 - final video = await ref.read(cameraProvider.notifier).stopVideoRecording(); 138 - _stopRecordingTimer(); 139 - 140 - setState(() { 141 - _isRecording = false; 142 - _recordingProgress = 0.0; 143 - _recordingTimeText = '00:00 / 03:00'; 144 - _recordingSeconds = 0; 145 - }); 146 - 147 - if (video != null && mounted) { 148 - if (mounted) { 149 - if (widget.isStoryMode) { 150 - context.router.push(StoryReviewRoute(videoPath: video.path, imageFile: XFile(''))); 151 - } else { 152 - context.router.push(VideoReviewRoute(videoPath: video.path)); 153 - } 154 - } 155 - } 156 - } else { 157 - final success = await ref.read(cameraProvider.notifier).startVideoRecording(); 158 - if (success) { 159 - setState(() { 160 - _isRecording = true; 161 - }); 162 - _startRecordingTimer(); 163 - } 164 - } 165 - } 166 - 167 - void _startRecordingTimer() { 168 - _recordingTimer = Timer.periodic(const Duration(seconds: 1), (timer) { 169 - if (_recordingSeconds >= _maxRecordingSeconds) { 170 - _toggleVideoRecording(); 171 - return; 172 - } 173 - 174 - setState(() { 175 - _recordingSeconds++; 176 - _recordingProgress = _recordingSeconds / _maxRecordingSeconds; 177 - 178 - final minutes = _recordingSeconds ~/ 60; 179 - final seconds = _recordingSeconds % 60; 180 - final minutesStr = minutes.toString().padLeft(2, '0'); 181 - final secondsStr = seconds.toString().padLeft(2, '0'); 182 - 183 - _recordingTimeText = '$minutesStr:$secondsStr / 03:00'; 184 - }); 185 - }); 186 - } 187 - 188 - void _stopRecordingTimer() { 189 - _recordingTimer?.cancel(); 190 - _recordingTimer = null; 191 - } 192 - 193 - @override 194 - Widget build(BuildContext context) { 195 - var cameraState = ref.watch(cameraProvider); 196 - return Scaffold( 197 - backgroundColor: Theme.of(context).colorScheme.surface, 198 - 199 - body: Stack( 200 - children: [ 201 - if (_cameraPermissionDenied) 202 - Positioned.fill(child: CameraPermissionRequest(onRequestPermission: () => cameraState = ref.watch(cameraProvider))) 203 - else 204 - CameraView(cameraController: cameraState.value?.controller, isInitialized: cameraState.value?.isInitialized ?? false), 205 - 206 - SafeArea( 207 - child: Stack( 208 - children: [ 209 - Positioned( 210 - top: 20, 211 - left: 20, 212 - child: GestureDetector( 213 - onTap: () => context.router.maybePop(), 214 - child: Container( 215 - padding: const EdgeInsets.all(8), 216 - decoration: BoxDecoration(color: Colors.black.withAlpha(100), shape: BoxShape.circle), 217 - child: const Icon(FluentIcons.dismiss_24_regular, color: Colors.white, size: 24), 218 - ), 219 - ), 220 - ), 221 - 222 - Positioned( 223 - top: 20, 224 - left: 0, 225 - right: 0, 226 - child: Center( 227 - child: ModeSelector( 228 - isVideoMode: _isVideoMode, 229 - onModeSelected: (isVideoMode) => setState(() => _isVideoMode = isVideoMode), 230 - ), 231 - ), 232 - ), 233 - 234 - Positioned( 235 - bottom: 30, 236 - left: 0, 237 - right: 0, 238 - child: Column( 239 - children: [ 240 - if (_isVideoMode) ...[ 241 - RecordingBar(isRecording: _isRecording, progress: _recordingProgress, timeText: _recordingTimeText), 242 - const SizedBox(height: 20), 243 - ], 244 - 245 - CameraControls( 246 - isVideoMode: _isVideoMode, 247 - isRecording: _isRecording, 248 - onCapturePressed: _onCapturePressed, 249 - onFlipCameraPressed: () => ref.read(cameraProvider.notifier).flipCamera(), 250 - onGalleryPressed: _onVideoGalleryPressed, 251 - onImageGalleryPressed: _onImageGalleryPressed, 252 - ), 253 - ], 254 - ), 255 - ), 256 - ], 257 - ), 258 - ), 259 - ], 260 - ), 261 - ); 262 - } 263 - }
+99 -47
lib/src/features/posting/ui/pages/image_review_page.dart
··· 1 1 import 'dart:io'; 2 2 3 3 import 'package:atproto/atproto.dart'; 4 + import 'package:atproto_core/atproto_core.dart'; 4 5 import 'package:auto_route/auto_route.dart'; 5 6 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 6 - import 'package:flutter/material.dart'; 7 + import 'package:flutter/material.dart' hide Image; 7 8 import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 9 import 'package:get_it/get_it.dart'; 9 10 import 'package:image_picker/image_picker.dart'; 10 - import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Image; 11 + import 'package:imgly_editor/imgly_editor.dart'; 12 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 13 + import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 11 14 import 'package:sparksocial/src/core/routing/app_router.dart'; 12 15 import 'package:sparksocial/src/core/widgets/alt_text_editor_dialog.dart'; 13 - import 'package:sparksocial/src/features/posting/providers/upload_provider.dart'; 16 + import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 17 + import 'package:sparksocial/src/features/posting/providers/post_story.dart'; 18 + import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 14 19 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 15 20 16 - void showFullscreenImage(BuildContext context, XFile imageFile) { 17 - showDialog( 18 - context: context, 19 - builder: (context) => Dialog( 20 - backgroundColor: Colors.transparent, 21 - insetPadding: EdgeInsets.zero, 22 - child: GestureDetector( 23 - onTap: () => context.router.maybePop(), 24 - child: InteractiveViewer(child: Center(child: Image.file(File(imageFile.path)))), 25 - ), 26 - ), 27 - ); 28 - } 29 - 30 21 @RoutePage() 31 22 class ImageReviewPage extends ConsumerStatefulWidget { 32 - const ImageReviewPage({required this.imageFiles, super.key}); 23 + const ImageReviewPage({required this.imageFiles, required this.storyMode, super.key}); 33 24 final List<XFile> imageFiles; 25 + final bool storyMode; 34 26 35 27 @override 36 28 ConsumerState<ImageReviewPage> createState() => _ImageReviewPageState(); ··· 46 38 final ImagePicker _picker = ImagePicker(); 47 39 final Map<String, String> _altTexts = {}; 48 40 late final FeedRepository _feedRepository; 41 + final Map<String, String?> _sceneMap = {}; 42 + 43 + Future<void> showImageEditor(BuildContext context, XFile imageFile) async { 44 + final handle = ref.read(sessionProvider)?.handle; 45 + // if there's a scene use it, or else create a new one from the image 46 + final source = _sceneMap[imageFile.path] != null 47 + ? Source.fromScene(_sceneMap[imageFile.path]!) 48 + : Source.fromImage('file://${imageFile.path}'); 49 + 50 + final newImage = await GetIt.I<IMGLYRepository>().openImageEditor(userID: handle, source: source); 51 + // If the user edited the image, replace the original file in the list 52 + if (newImage != null) { 53 + if (newImage.artifact != null) { 54 + final uri = Uri.parse(newImage.artifact!).toFilePath(windows: false); 55 + setState(() { 56 + _imageFiles[_currentPage] = XFile(uri); 57 + _sceneMap[uri] = newImage.scene; 58 + }); 59 + } 60 + } 61 + } 49 62 50 63 @override 51 64 void initState() { ··· 74 87 final initialText = _altTexts[path] ?? ''; 75 88 final result = await showDialog<String>( 76 89 context: context, 77 - builder: (context) => AltTextEditorDialog(imageFile: imageFile, initialAltText: initialText), 90 + builder: (context) => AltTextEditorDialog(imageFile: imageFile.path, initialAltText: initialText), 78 91 ); 79 92 if (result == null) return; 80 93 setState(() { ··· 108 121 _isPosting = true; 109 122 }); 110 123 try { 111 - final uploadService = ref.read(uploadProvider.notifier); 112 124 final crosspostEnabled = ref.read(settingsProvider).postToBskyEnabled; 113 125 final description = _descriptionController.text; 114 - final taskId = uploadService.registerTask('image'); 115 - uploadService.startTask(taskId); 116 - if (mounted) { 117 - context.router.pushAndPopUntil(const MainRoute(), predicate: (route) => false); 126 + StrongRef result; 127 + if (widget.storyMode) { 128 + final uploadedImage = await _feedRepository.uploadImages( 129 + imageFiles: _imageFiles, 130 + altTexts: _altTexts, 131 + ); 132 + if (uploadedImage.isEmpty) { 133 + throw Exception('No images uploaded'); 134 + } 135 + result = ref 136 + .read( 137 + postStoryProvider( 138 + Embed.image(images: uploadedImage), 139 + ), 140 + ) 141 + .value!; 142 + } else { 143 + // Post as a regular image post 144 + result = await _feedRepository.postImages(description, _imageFiles, _altTexts, crosspostToBsky: crosspostEnabled); 118 145 } 119 - final result = await _feedRepository.postImages(description, _imageFiles, _altTexts, crosspostToBsky: crosspostEnabled); 120 - uploadService.completeTask(taskId); 121 146 return result; 122 147 } catch (e) { 123 148 if (!mounted) return null; ··· 127 152 ScaffoldMessenger.of( 128 153 context, 129 154 ).showSnackBar(SnackBar(content: Text('Failed to create post: $e'), backgroundColor: Colors.red)); 130 - final uploadService = ref.read(uploadProvider.notifier); 131 - final tasks = uploadService.registerTask('image'); 132 - uploadService.failTask(tasks, e.toString()); 133 155 } 134 156 return null; 135 157 } ··· 171 193 itemBuilder: (context, index) { 172 194 final image = _imageFiles[index]; 173 195 return GestureDetector( 174 - onTap: () => showFullscreenImage(context, image), 196 + onTap: () => showImageEditor(context, image), 175 197 child: Stack( 176 198 children: [ 177 199 Container( ··· 183 205 ), 184 206 Positioned( 185 207 bottom: 8, 208 + left: 8, 209 + child: Container( 210 + padding: const EdgeInsets.all(4), 211 + decoration: BoxDecoration( 212 + color: Colors.black.withAlpha(150), 213 + borderRadius: BorderRadius.circular(4), 214 + ), 215 + child: const Row( 216 + children: [ 217 + Icon(Icons.edit, color: Colors.white, size: 16), 218 + SizedBox(width: 4), 219 + Text( 220 + 'Tap to edit', 221 + style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), 222 + ), 223 + ], 224 + ), 225 + ), 226 + ), 227 + Positioned( 228 + bottom: 8, 186 229 right: 8, 187 230 child: Row( 188 231 mainAxisSize: MainAxisSize.min, ··· 263 306 ], 264 307 ), 265 308 ), 266 - const SizedBox(height: 20), 309 + if (!widget.storyMode) const SizedBox(height: 20), 267 310 // Add More Images Button 268 - SizedBox( 269 - width: double.infinity, 270 - child: ElevatedButton.icon( 271 - onPressed: canPickMore ? _pickMoreImages : null, 272 - icon: const Icon(FluentIcons.add_24_regular), 273 - label: Text( 274 - canPickMore ? 'Add More Images (${_imageFiles.length}/$_maxImages)' : 'Image Limit Reached', 275 - ), 276 - style: ElevatedButton.styleFrom( 277 - backgroundColor: Theme.of(context).colorScheme.primary, 278 - disabledBackgroundColor: Theme.of(context).colorScheme.primary.withAlpha(100), 279 - foregroundColor: Theme.of(context).colorScheme.onPrimary, 280 - padding: const EdgeInsets.symmetric(vertical: 14), 281 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 311 + if (!widget.storyMode) 312 + SizedBox( 313 + width: double.infinity, 314 + child: ElevatedButton.icon( 315 + onPressed: canPickMore ? _pickMoreImages : null, 316 + icon: const Icon(FluentIcons.add_24_regular), 317 + label: Text( 318 + canPickMore ? 'Add More Images (${_imageFiles.length}/$_maxImages)' : 'Image Limit Reached', 319 + ), 320 + style: ElevatedButton.styleFrom( 321 + backgroundColor: Theme.of(context).colorScheme.primary, 322 + disabledBackgroundColor: Theme.of(context).colorScheme.primary.withAlpha(100), 323 + foregroundColor: Theme.of(context).colorScheme.onPrimary, 324 + padding: const EdgeInsets.symmetric(vertical: 14), 325 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 326 + ), 282 327 ), 283 328 ), 284 - ), 285 329 const SizedBox(height: 20), 286 330 // Description input with character count 287 331 Builder( ··· 407 451 : () async { 408 452 final postRef = await _uploadImagesAndPost(); 409 453 if (context.mounted && postRef != null) { 410 - context.router.push(StandalonePostRoute(postUri: postRef.uri.toString())); 454 + context.router.popUntilRoot(); 455 + final did = ref.read(sessionProvider)?.did; 456 + if (did != null) { 457 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), false)); 458 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), true)); 459 + } 460 + if (!widget.storyMode) { 461 + context.router.push(StandalonePostRoute(postUri: postRef.uri.toString())); 462 + } 411 463 } 412 464 }, 413 465 style: ElevatedButton.styleFrom(
-374
lib/src/features/posting/ui/pages/story_review_page.dart
··· 1 - import 'dart:io'; 2 - 3 - import 'package:auto_route/auto_route.dart'; 4 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 - import 'package:flutter/material.dart'; 6 - import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 - import 'package:get_it/get_it.dart'; 8 - import 'package:image_picker/image_picker.dart'; 9 - import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Image; 10 - import 'package:sparksocial/src/core/routing/app_router.dart'; 11 - import 'package:sparksocial/src/core/widgets/alt_text_editor_dialog.dart'; 12 - import 'package:sparksocial/src/features/posting/providers/post_story.dart'; 13 - import 'package:sparksocial/src/features/posting/providers/upload_provider.dart'; 14 - import 'package:sparksocial/src/features/posting/providers/video_upload_provider.dart'; 15 - import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 16 - import 'package:sparksocial/src/features/posting/ui/widgets/video_thumbnail.dart'; 17 - import 'package:video_player/video_player.dart'; 18 - 19 - @RoutePage() 20 - class StoryReviewPage extends ConsumerStatefulWidget { 21 - const StoryReviewPage({required this.videoPath, required this.imageFile, super.key}); 22 - final String videoPath; 23 - final XFile imageFile; 24 - 25 - @override 26 - ConsumerState<StoryReviewPage> createState() => _StoryReviewPageState(); 27 - } 28 - 29 - class _StoryReviewPageState extends ConsumerState<StoryReviewPage> { 30 - VideoPlayerController? _controller; 31 - bool _isPosting = false; 32 - String _altText = ''; 33 - 34 - @override 35 - void initState() { 36 - super.initState(); 37 - if (widget.videoPath.isNotEmpty) { 38 - _initVideoPlayer(); 39 - } 40 - } 41 - 42 - void _initVideoPlayer() { 43 - if (widget.videoPath.isEmpty) return; 44 - 45 - var videoPath = widget.videoPath; 46 - if (videoPath.startsWith('file://')) { 47 - videoPath = videoPath.replaceFirst('file://', ''); 48 - } 49 - 50 - _controller = VideoPlayerController.file(File(videoPath)) 51 - ..initialize().then((_) { 52 - if (mounted) { 53 - setState(() {}); 54 - _controller!.setLooping(true); 55 - } 56 - }); 57 - } 58 - 59 - @override 60 - void dispose() { 61 - _controller?.dispose(); 62 - super.dispose(); 63 - } 64 - 65 - Future<void> _postStory() async { 66 - if (_isPosting) return; 67 - 68 - setState(() { 69 - _isPosting = true; 70 - }); 71 - 72 - try { 73 - final uploadService = ref.read(uploadProvider.notifier); 74 - final taskId = uploadService.registerTask('story'); 75 - uploadService.startTask(taskId); 76 - 77 - if (widget.videoPath.isNotEmpty) { 78 - await _postVideoStory(); 79 - } else if (widget.imageFile.path.isNotEmpty) { 80 - await _postImageStory(); 81 - } 82 - 83 - uploadService.completeTask(taskId); 84 - 85 - if (mounted) { 86 - context.router.navigate(const MainRoute()); 87 - } 88 - } catch (e) { 89 - if (mounted) { 90 - setState(() { 91 - _isPosting = false; 92 - }); 93 - 94 - ScaffoldMessenger.of( 95 - context, 96 - ).showSnackBar(SnackBar(content: Text('Failed to post story: $e'), backgroundColor: Colors.red)); 97 - 98 - final uploadService = ref.read(uploadProvider.notifier); 99 - final taskId = uploadService.registerTask('story'); 100 - uploadService.failTask(taskId, e.toString()); 101 - } 102 - } 103 - } 104 - 105 - Future<void> _postVideoStory() async { 106 - final videoService = ref.read(videoUploadProvider(widget.videoPath).notifier); 107 - await videoService.processVideo(widget.videoPath); 108 - final state = ref.read(videoUploadProvider(widget.videoPath)); 109 - if (state is VideoUploadStateVideoProcessed) { 110 - ref.read( 111 - postStoryProvider( 112 - EmbedVideo(video: state.blob), 113 - selfLabels: [], 114 - tags: [], 115 - ), 116 - ); 117 - } 118 - } 119 - 120 - Future<void> _postImageStory() async { 121 - final feedRepository = GetIt.I<SprkRepository>().feed; 122 - final uploadedImageMaps = await feedRepository.uploadImages( 123 - imageFiles: [widget.imageFile], 124 - altTexts: {widget.imageFile.path: _altText}, 125 - ); 126 - 127 - if (uploadedImageMaps.isNotEmpty) { 128 - ref.read( 129 - postStoryProvider( 130 - EmbedImage(images: uploadedImageMaps), 131 - selfLabels: [], 132 - tags: [], 133 - ), 134 - ); 135 - } else { 136 - throw Exception('Failed to upload image - no image data returned'); 137 - } 138 - } 139 - 140 - @override 141 - Widget build(BuildContext context) { 142 - return Scaffold( 143 - backgroundColor: Theme.of(context).colorScheme.surface, 144 - appBar: AppBar( 145 - backgroundColor: Theme.of(context).colorScheme.surface, 146 - elevation: 0, 147 - leading: IconButton( 148 - icon: Icon(FluentIcons.arrow_left_24_regular, color: Theme.of(context).colorScheme.onSurface), 149 - onPressed: () => context.router.maybePop(), 150 - ), 151 - title: Text('Review Story', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), 152 - ), 153 - body: SafeArea( 154 - child: Column( 155 - children: [ 156 - Expanded( 157 - child: SingleChildScrollView( 158 - child: Padding( 159 - padding: const EdgeInsets.all(16), 160 - child: Column( 161 - children: [ 162 - // Media preview 163 - LayoutBuilder( 164 - builder: (context, constraints) { 165 - final maxWidth = constraints.maxWidth; 166 - const maxHeight = 320.0; 167 - 168 - // IMAGE PREVIEW (when no video provided) 169 - if (_controller == null) { 170 - if (widget.imageFile.path.isNotEmpty) { 171 - return SizedBox( 172 - height: maxHeight, 173 - width: maxWidth, 174 - child: Stack( 175 - children: [ 176 - ClipRRect( 177 - borderRadius: BorderRadius.circular(12), 178 - child: Image.file( 179 - File(widget.imageFile.path), 180 - fit: BoxFit.cover, 181 - width: maxWidth, 182 - height: maxHeight, 183 - ), 184 - ), 185 - Positioned( 186 - bottom: 12, 187 - right: 12, 188 - child: Material( 189 - color: Colors.black.withAlpha(100), 190 - borderRadius: BorderRadius.circular(8), 191 - child: InkWell( 192 - onTap: () async { 193 - final result = await showDialog<String>( 194 - context: context, 195 - builder: (context) => 196 - AltTextEditorDialog(imageFile: widget.imageFile, initialAltText: _altText), 197 - ); 198 - 199 - if (result != null) { 200 - setState(() => _altText = result.trim()); 201 - } 202 - }, 203 - borderRadius: BorderRadius.circular(8), 204 - child: const Padding( 205 - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), 206 - child: Row( 207 - mainAxisSize: MainAxisSize.min, 208 - children: [ 209 - Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 210 - SizedBox(width: 4), 211 - Text( 212 - 'ALT', 213 - style: TextStyle( 214 - color: Colors.white, 215 - fontSize: 12, 216 - fontWeight: FontWeight.bold, 217 - ), 218 - ), 219 - ], 220 - ), 221 - ), 222 - ), 223 - ), 224 - ), 225 - ], 226 - ), 227 - ); 228 - } 229 - 230 - // Fallback loader if neither video nor image is ready 231 - return const SizedBox( 232 - height: maxHeight, 233 - width: double.infinity, 234 - child: Center(child: CircularProgressIndicator()), 235 - ); 236 - } 237 - 238 - // VIDEO PREVIEW (controller exists) 239 - if (!_controller!.value.isInitialized) { 240 - return SizedBox( 241 - height: maxHeight, 242 - width: double.infinity, 243 - child: _controller!.value.hasError 244 - ? Container( 245 - color: Colors.grey.shade900, 246 - alignment: Alignment.center, 247 - child: Text( 248 - 'Video preview unavailable', 249 - style: TextStyle( 250 - color: Theme.of(context).colorScheme.onSurface, 251 - fontSize: 16, 252 - fontWeight: FontWeight.bold, 253 - ), 254 - textAlign: TextAlign.center, 255 - ), 256 - ) 257 - : Container( 258 - color: Colors.grey, 259 - alignment: Alignment.center, 260 - child: const CircularProgressIndicator(), 261 - ), 262 - ); 263 - } 264 - 265 - final aspectRatio = _controller!.value.aspectRatio; 266 - var width = maxWidth; 267 - var height = width / aspectRatio; 268 - if (height > maxHeight) { 269 - height = maxHeight; 270 - width = height * aspectRatio; 271 - } 272 - 273 - return SizedBox( 274 - height: height, 275 - width: width, 276 - child: Stack( 277 - children: [ 278 - ClipRRect( 279 - borderRadius: BorderRadius.circular(12), 280 - child: AspectRatio( 281 - aspectRatio: aspectRatio, 282 - child: VideoThumbnail(controller: _controller!), 283 - ), 284 - ), 285 - Positioned( 286 - bottom: 12, 287 - right: 12, 288 - child: Material( 289 - color: Colors.black.withValues(alpha: 0.5), 290 - borderRadius: BorderRadius.circular(8), 291 - child: InkWell( 292 - onTap: () async { 293 - final wasPlaying = _controller?.value.isPlaying ?? false; 294 - _controller?.pause(); 295 - 296 - final result = await showDialog<String>( 297 - context: context, 298 - builder: (context) => 299 - AltTextEditorDialog(imageFile: widget.imageFile, initialAltText: _altText), 300 - ); 301 - 302 - if (result != null) { 303 - setState(() { 304 - _altText = result.trim(); 305 - }); 306 - } 307 - 308 - if (wasPlaying && mounted && _controller != null) { 309 - _controller!.play(); 310 - } 311 - }, 312 - borderRadius: BorderRadius.circular(8), 313 - child: Padding( 314 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 315 - child: Row( 316 - mainAxisSize: MainAxisSize.min, 317 - children: [ 318 - const Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 319 - const SizedBox(width: 4), 320 - Text( 321 - _altText.isEmpty ? 'ALT' : 'ALT', 322 - style: const TextStyle( 323 - color: Colors.white, 324 - fontSize: 12, 325 - fontWeight: FontWeight.bold, 326 - ), 327 - ), 328 - ], 329 - ), 330 - ), 331 - ), 332 - ), 333 - ), 334 - ], 335 - ), 336 - ); 337 - }, 338 - ), 339 - ], 340 - ), 341 - ), 342 - ), 343 - ), 344 - Padding( 345 - padding: const EdgeInsets.all(16), 346 - child: SizedBox( 347 - width: double.infinity, 348 - child: ElevatedButton( 349 - onPressed: _isPosting ? null : _postStory, 350 - style: ElevatedButton.styleFrom( 351 - backgroundColor: Theme.of(context).colorScheme.primary, 352 - padding: const EdgeInsets.symmetric(vertical: 16), 353 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 354 - disabledBackgroundColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), 355 - ), 356 - child: _isPosting 357 - ? const SizedBox( 358 - height: 20, 359 - width: 20, 360 - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), 361 - ) 362 - : const Text( 363 - 'Post story', 364 - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16), 365 - ), 366 - ), 367 - ), 368 - ), 369 - ], 370 - ), 371 - ), 372 - ); 373 - } 374 - }
-174
lib/src/features/posting/ui/pages/video_playback_page.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 - import 'package:flutter/material.dart'; 4 - import 'package:flutter/services.dart'; 5 - import 'package:video_player/video_player.dart'; 6 - 7 - @RoutePage() 8 - class VideoPlaybackPage extends StatefulWidget { 9 - const VideoPlaybackPage({required this.controller, super.key}); 10 - final VideoPlayerController controller; 11 - 12 - @override 13 - State<VideoPlaybackPage> createState() => _VideoPlaybackPageState(); 14 - } 15 - 16 - class _VideoPlaybackPageState extends State<VideoPlaybackPage> { 17 - bool _showControls = true; 18 - bool _isPlaying = false; 19 - 20 - @override 21 - void initState() { 22 - super.initState(); 23 - _isPlaying = widget.controller.value.isPlaying; 24 - SystemChrome.setPreferredOrientations([ 25 - DeviceOrientation.portraitUp, 26 - DeviceOrientation.landscapeLeft, 27 - DeviceOrientation.landscapeRight, 28 - ]); 29 - 30 - // Hide controls after a delay 31 - _autoHideControls(); 32 - } 33 - 34 - @override 35 - void dispose() { 36 - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); 37 - super.dispose(); 38 - } 39 - 40 - void _togglePlayPause() { 41 - setState(() { 42 - if (widget.controller.value.isPlaying) { 43 - widget.controller.pause(); 44 - _isPlaying = false; 45 - } else { 46 - widget.controller.play(); 47 - _isPlaying = true; 48 - _autoHideControls(); 49 - } 50 - }); 51 - } 52 - 53 - void _toggleControls() { 54 - setState(() { 55 - _showControls = !_showControls; 56 - if (_showControls) { 57 - _autoHideControls(); 58 - } 59 - }); 60 - } 61 - 62 - void _autoHideControls() { 63 - if (_showControls) { 64 - Future.delayed(const Duration(seconds: 3), () { 65 - if (mounted && _isPlaying) { 66 - setState(() { 67 - _showControls = false; 68 - }); 69 - } 70 - }); 71 - } 72 - } 73 - 74 - String _formatDuration(Duration duration) { 75 - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); 76 - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); 77 - return '$minutes:$seconds'; 78 - } 79 - 80 - @override 81 - Widget build(BuildContext context) { 82 - final duration = widget.controller.value.duration; 83 - final position = widget.controller.value.position; 84 - 85 - return Scaffold( 86 - backgroundColor: Colors.black, 87 - body: GestureDetector( 88 - onTap: _toggleControls, 89 - child: Stack( 90 - children: [ 91 - // Video Player 92 - Center( 93 - child: AspectRatio(aspectRatio: widget.controller.value.aspectRatio, child: VideoPlayer(widget.controller)), 94 - ), 95 - 96 - // Controls overlay 97 - if (_showControls) 98 - Positioned.fill( 99 - child: ColoredBox( 100 - color: Colors.black.withAlpha(100), 101 - child: Stack( 102 - alignment: Alignment.center, 103 - children: [ 104 - // Play/Pause button 105 - IconButton( 106 - icon: Icon( 107 - _isPlaying ? FluentIcons.pause_48_filled : FluentIcons.play_48_filled, 108 - color: Colors.white, 109 - size: 60, 110 - ), 111 - onPressed: _togglePlayPause, 112 - ), 113 - 114 - // Back button 115 - Positioned( 116 - top: MediaQuery.of(context).padding.top + 16, 117 - left: 16, 118 - child: IconButton( 119 - icon: const Icon(FluentIcons.arrow_left_24_filled, color: Colors.white, size: 28), 120 - onPressed: () => context.router.maybePop(), 121 - ), 122 - ), 123 - 124 - // Progress bar and time 125 - Positioned( 126 - bottom: MediaQuery.of(context).padding.bottom + 24, 127 - left: 16, 128 - right: 16, 129 - child: Column( 130 - mainAxisSize: MainAxisSize.min, 131 - children: [ 132 - // Time indicators 133 - Padding( 134 - padding: const EdgeInsets.symmetric(horizontal: 4), 135 - child: Row( 136 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 137 - children: [ 138 - Text(_formatDuration(position), style: const TextStyle(color: Colors.white, fontSize: 12)), 139 - Text(_formatDuration(duration), style: const TextStyle(color: Colors.white, fontSize: 12)), 140 - ], 141 - ), 142 - ), 143 - 144 - // Progress slider 145 - SliderTheme( 146 - data: SliderThemeData( 147 - trackHeight: 4, 148 - activeTrackColor: Colors.pink, 149 - inactiveTrackColor: Colors.white.withAlpha(77), 150 - thumbColor: Colors.pink, 151 - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), 152 - overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), 153 - ), 154 - child: Slider( 155 - value: position.inMilliseconds.toDouble(), 156 - max: duration.inMilliseconds.toDouble(), 157 - onChanged: (value) { 158 - widget.controller.seekTo(Duration(milliseconds: value.toInt())); 159 - }, 160 - ), 161 - ), 162 - ], 163 - ), 164 - ), 165 - ], 166 - ), 167 - ), 168 - ), 169 - ], 170 - ), 171 - ), 172 - ); 173 - } 174 - }
+149 -151
lib/src/features/posting/ui/pages/video_review_page.dart
··· 1 1 import 'dart:io'; 2 2 3 - import 'package:atproto/atproto.dart'; 3 + import 'package:atproto_core/atproto_core.dart'; 4 4 import 'package:auto_route/auto_route.dart'; 5 5 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 6 6 import 'package:flutter/material.dart'; 7 7 import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 + import 'package:get_it/get_it.dart'; 9 + import 'package:image_picker/image_picker.dart'; 10 + import 'package:imgly_editor/imgly_editor.dart'; 11 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 8 12 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 13 import 'package:sparksocial/src/core/widgets/alt_text_editor_dialog.dart'; 10 - import 'package:sparksocial/src/features/posting/providers/upload_provider.dart'; 14 + import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 11 15 import 'package:sparksocial/src/features/posting/providers/video_upload_provider.dart'; 12 - import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 13 - import 'package:sparksocial/src/features/posting/ui/widgets/video_thumbnail.dart'; 16 + import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 14 17 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 15 - import 'package:video_player/video_player.dart'; 16 18 17 19 @RoutePage() 18 20 class VideoReviewPage extends ConsumerStatefulWidget { 19 - const VideoReviewPage({required this.videoPath, super.key}); 20 - final String videoPath; 21 + const VideoReviewPage({required this.editorResult, required this.storyMode, super.key}); 22 + final EditorResult editorResult; 23 + final bool storyMode; 21 24 22 25 @override 23 26 ConsumerState<VideoReviewPage> createState() => _VideoReviewPageState(); 24 27 } 25 28 26 29 class _VideoReviewPageState extends ConsumerState<VideoReviewPage> { 27 - late VideoPlayerController _controller; 28 30 final TextEditingController _descriptionController = TextEditingController(); 29 31 bool _isPosting = false; 30 32 String _videoAltText = ''; 33 + late EditorResult _editorResult; 34 + late XFile _video; 31 35 32 36 @override 33 37 void initState() { 34 38 super.initState(); 35 - _initVideoPlayer(); 36 - } 37 - 38 - void _initVideoPlayer() { 39 - var videoPath = widget.videoPath; 40 - 41 - // Handle file:// URL scheme 42 - if (videoPath.startsWith('file://')) { 43 - videoPath = videoPath.replaceFirst('file://', ''); 44 - } 45 - 46 - _controller = VideoPlayerController.file(File(videoPath)) 47 - ..initialize().then((_) { 48 - setState(() {}); 49 - _controller.setLooping(true); 50 - }); 39 + _editorResult = widget.editorResult; 40 + _video = XFile(Uri.parse(widget.editorResult.artifact!).toFilePath(windows: false)); 51 41 } 52 42 53 43 @override 54 44 void dispose() { 55 - _controller.dispose(); 56 45 _descriptionController.dispose(); 57 46 super.dispose(); 58 47 } 59 48 60 - Future<StrongRef?> _uploadVideo() async { 61 - if (_isPosting) return null; 49 + Future<void> _editAltText() async { 50 + final initialText = _videoAltText; 51 + final result = await showDialog<String>( 52 + context: context, 53 + builder: (context) => AltTextEditorDialog( 54 + imageFile: Uri.parse(_editorResult.artifact!).toFilePath(windows: false), 55 + initialAltText: initialText, 56 + ), 57 + ); 58 + if (result == null) return; 59 + setState(() { 60 + _videoAltText = result.trim(); 61 + }); 62 + } 63 + 64 + Future<void> _uploadVideo() async { 65 + if (_isPosting) return; 62 66 63 67 setState(() { 64 68 _isPosting = true; ··· 68 72 final description = _descriptionController.text; 69 73 final crosspostEnabled = ref.read(settingsProvider).postToBskyEnabled; 70 74 71 - // Register a new upload task 72 - final uploadNotifier = ref.read(uploadProvider.notifier); 73 - final taskId = uploadNotifier.registerTask('video'); 74 - uploadNotifier.startTask(taskId); 75 - 76 - // Navigate to home screen while upload continues in background 77 - if (mounted) { 78 - context.router.navigate(const MainRoute()); 79 - } 80 - 81 75 // Process and post the video with the video upload provider 82 - final videoUploadNotifier = ref.read(videoUploadProvider(widget.videoPath).notifier); 83 - await videoUploadNotifier.processAndPostVideo( 84 - videoPath: widget.videoPath, 85 - description: description, 86 - altText: _videoAltText, 87 - crosspostToBsky: crosspostEnabled, 76 + final postRef = await ref.read( 77 + processAndPostVideoProvider( 78 + videoPath: _video.path, 79 + description: description, 80 + altText: _videoAltText, 81 + crosspostToBsky: crosspostEnabled, 82 + storyMode: widget.storyMode, 83 + ).future, 88 84 ); 89 85 90 - // Mark task as completed 91 - uploadNotifier.completeTask(taskId); 92 - switch (ref.read(videoUploadProvider(widget.videoPath).select((state) => state))) { 93 - case VideoUploadStatePosted(:final postRef): 94 - return postRef; 95 - default: 96 - return null; 86 + setState(() { 87 + _isPosting = false; 88 + }); 89 + 90 + if (mounted) { 91 + context.router.popUntilRoot(); 92 + final did = ref.read(sessionProvider)?.did; 93 + if (did != null) { 94 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), false)); 95 + ref.invalidate(profileFeedProvider(AtUri.parse('at://$did'), true)); 96 + } 97 + if (postRef == null) { 98 + ScaffoldMessenger.of(context).showSnackBar( 99 + const SnackBar(content: Text('Failed to post video. Please try again.')), 100 + ); 101 + return; 102 + } else { 103 + ScaffoldMessenger.of(context).showSnackBar( 104 + const SnackBar(content: Text('Video posted successfully!')), 105 + ); 106 + if (!widget.storyMode) { 107 + context.router.push(StandalonePostRoute(postUri: postRef.uri.toString())); 108 + } 109 + } 97 110 } 98 111 } catch (e) { 99 112 if (mounted) { ··· 105 118 ScaffoldMessenger.of( 106 119 context, 107 120 ).showSnackBar(SnackBar(content: Text('Failed to upload video: $e'), backgroundColor: Colors.red)); 108 - 109 - // Update upload service with error state 110 - final uploadNotifier = ref.read(uploadProvider.notifier); 111 - final taskId = uploadNotifier.registerTask('video'); 112 - uploadNotifier.failTask(taskId, e.toString()); 113 121 } 114 122 } 115 - return null; 123 + return; 116 124 } 117 125 118 126 @override ··· 126 134 icon: Icon(FluentIcons.arrow_left_24_regular, color: Theme.of(context).colorScheme.onSurface), 127 135 onPressed: () => context.router.maybePop(), 128 136 ), 129 - title: Text('Review Video', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), 137 + title: Text('Edit Video', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), 130 138 ), 131 139 body: SafeArea( 132 140 child: Column( ··· 138 146 child: Column( 139 147 children: [ 140 148 // Video preview big on top with ALT overlay 141 - LayoutBuilder( 142 - builder: (context, constraints) { 143 - final maxWidth = constraints.maxWidth; 144 - const maxHeight = 320.0; 145 - if (!_controller.value.isInitialized) { 146 - return SizedBox( 147 - height: maxHeight, 148 - width: double.infinity, 149 - child: _controller.value.hasError 150 - ? Container( 151 - color: Colors.grey.shade900, 152 - alignment: Alignment.center, 153 - child: Text( 154 - 'Video preview unavailable', 155 - style: TextStyle( 156 - color: Theme.of(context).colorScheme.onSurface, 157 - fontSize: 16, 158 - fontWeight: FontWeight.bold, 159 - ), 160 - textAlign: TextAlign.center, 149 + Stack( 150 + children: [ 151 + ClipRRect( 152 + borderRadius: BorderRadius.circular(12), 153 + child: AspectRatio( 154 + aspectRatio: 1, 155 + child: GestureDetector( 156 + onTap: () async { 157 + final imgly = GetIt.I<IMGLYRepository>(); 158 + final handle = ref.read(sessionProvider)?.handle; 159 + final newResult = await imgly.openVideoEditor( 160 + userID: handle, 161 + source: Source.fromScene(_editorResult.scene!), 162 + ); 163 + if (newResult != null) { 164 + setState(() { 165 + _editorResult = newResult; 166 + _video = XFile(Uri.parse(newResult.artifact!).toFilePath(windows: false)); 167 + }); 168 + } 169 + }, 170 + child: Container( 171 + margin: const EdgeInsets.symmetric(horizontal: 4), 172 + decoration: BoxDecoration( 173 + borderRadius: BorderRadius.circular(8), 174 + image: DecorationImage( 175 + image: FileImage( 176 + File(Uri.tryParse(_editorResult.thumbnail ?? '')?.toFilePath(windows: false) ?? ''), 161 177 ), 162 - ) 163 - : Container( 164 - color: Colors.grey, 165 - alignment: Alignment.center, 166 - child: const CircularProgressIndicator(), 178 + fit: BoxFit.cover, 167 179 ), 168 - ); 169 - } 170 - final aspectRatio = _controller.value.aspectRatio; 171 - var width = maxWidth; 172 - var height = width / aspectRatio; 173 - if (height > maxHeight) { 174 - height = maxHeight; 175 - width = height * aspectRatio; 176 - } 177 - return SizedBox( 178 - height: height, 179 - width: width, 180 - child: Stack( 181 - children: [ 182 - ClipRRect( 183 - borderRadius: BorderRadius.circular(12), 184 - child: AspectRatio( 185 - aspectRatio: aspectRatio, 186 - child: VideoThumbnail(controller: _controller), 187 180 ), 188 181 ), 189 - // ALT button overlay (bottom right) 190 - Positioned( 191 - bottom: 12, 192 - right: 12, 193 - child: Material( 194 - color: Colors.black.withAlpha(100), 195 - borderRadius: BorderRadius.circular(8), 196 - child: InkWell( 197 - onTap: () async { 198 - final wasPlaying = _controller.value.isPlaying; 199 - _controller.pause(); 200 - final result = await showDialog<String>( 201 - context: context, 202 - builder: (context) => AltTextEditorDialog(initialAltText: _videoAltText), 203 - ); 204 - if (result != null) { 205 - setState(() { 206 - _videoAltText = result.trim(); 207 - }); 208 - } 209 - if (wasPlaying && mounted) { 210 - _controller.play(); 211 - } 212 - }, 213 - borderRadius: BorderRadius.circular(8), 214 - child: Padding( 215 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 216 - child: Row( 217 - mainAxisSize: MainAxisSize.min, 218 - children: [ 219 - const Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 220 - const SizedBox(width: 4), 221 - Text( 222 - _videoAltText.isEmpty ? 'ALT' : 'ALT', 223 - style: const TextStyle( 224 - color: Colors.white, 225 - fontSize: 12, 226 - fontWeight: FontWeight.bold, 227 - ), 228 - ), 229 - ], 182 + ), 183 + ), 184 + ), 185 + Positioned( 186 + bottom: 8, 187 + left: 8, 188 + child: Container( 189 + padding: const EdgeInsets.all(4), 190 + decoration: BoxDecoration( 191 + color: Colors.black.withAlpha(150), 192 + borderRadius: BorderRadius.circular(4), 193 + ), 194 + child: const Row( 195 + children: [ 196 + Icon(Icons.edit, color: Colors.white, size: 16), 197 + SizedBox(width: 4), 198 + Text( 199 + 'Tap to edit', 200 + style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), 201 + ), 202 + ], 203 + ), 204 + ), 205 + ), 206 + // ALT button overlay (bottom right) 207 + Positioned( 208 + bottom: 12, 209 + right: 12, 210 + child: Material( 211 + color: Colors.black.withAlpha(100), 212 + borderRadius: BorderRadius.circular(8), 213 + child: InkWell( 214 + onTap: _editAltText, 215 + borderRadius: BorderRadius.circular(8), 216 + child: const Padding( 217 + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), 218 + child: Row( 219 + mainAxisSize: MainAxisSize.min, 220 + children: [ 221 + Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 222 + SizedBox(width: 4), 223 + Text( 224 + 'ALT', 225 + style: TextStyle( 226 + color: Colors.white, 227 + fontSize: 12, 228 + fontWeight: FontWeight.bold, 230 229 ), 231 230 ), 232 - ), 231 + ], 233 232 ), 234 233 ), 235 - ], 234 + ), 236 235 ), 237 - ); 238 - }, 236 + ), 237 + ], 239 238 ), 240 239 const SizedBox(height: 20), 241 240 // Description input with character count ··· 257 256 style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurface), 258 257 decoration: InputDecoration( 259 258 hintText: 'Add a description... (optional)', 260 - hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), 259 + hintStyle: theme.textTheme.bodyLarge?.copyWith( 260 + color: theme.colorScheme.onSurfaceVariant, 261 + ), 261 262 border: OutlineInputBorder( 262 263 borderRadius: BorderRadius.circular(12), 263 264 borderSide: BorderSide(color: theme.colorScheme.outline), ··· 336 337 onPressed: _isPosting 337 338 ? null 338 339 : () async { 339 - final postRef = await _uploadVideo(); 340 - if (context.mounted && postRef != null) { 341 - context.router.push(StandalonePostRoute(postUri: postRef.uri.toString())); 342 - } 340 + await _uploadVideo(); 343 341 }, 344 342 style: ElevatedButton.styleFrom( 345 343 backgroundColor: Theme.of(context).colorScheme.primary,
-82
lib/src/features/posting/ui/widgets/video_thumbnail.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 - import 'package:flutter/material.dart'; 4 - import 'package:sparksocial/src/core/routing/app_router.dart'; 5 - import 'package:video_player/video_player.dart'; 6 - 7 - class VideoThumbnail extends StatefulWidget { 8 - const VideoThumbnail({required this.controller, super.key}); 9 - final VideoPlayerController controller; 10 - 11 - @override 12 - State<VideoThumbnail> createState() => _VideoThumbnailState(); 13 - } 14 - 15 - class _VideoThumbnailState extends State<VideoThumbnail> { 16 - bool get _isPlaying => widget.controller.value.isPlaying; 17 - 18 - void _togglePlayPause() { 19 - setState(() { 20 - if (_isPlaying) { 21 - widget.controller.pause(); 22 - } else { 23 - widget.controller.play(); 24 - } 25 - }); 26 - } 27 - 28 - @override 29 - Widget build(BuildContext context) { 30 - if (!widget.controller.value.isInitialized) { 31 - return AspectRatio( 32 - aspectRatio: 16 / 9, 33 - child: Container( 34 - color: Colors.grey[600], 35 - child: const Center(child: CircularProgressIndicator(color: Colors.white)), 36 - ), 37 - ); 38 - } 39 - 40 - return GestureDetector( 41 - onTap: _togglePlayPause, 42 - onLongPress: () { 43 - widget.controller.pause(); 44 - context.router.push(VideoPlaybackRoute(controller: widget.controller)); 45 - }, 46 - child: AspectRatio( 47 - aspectRatio: widget.controller.value.aspectRatio, 48 - child: Stack( 49 - alignment: Alignment.center, 50 - children: [ 51 - Container( 52 - clipBehavior: Clip.antiAlias, 53 - decoration: BoxDecoration(color: Colors.grey[800], borderRadius: BorderRadius.circular(8)), 54 - child: VideoPlayer(widget.controller), 55 - ), 56 - if (!_isPlaying) 57 - Container( 58 - width: 50, 59 - height: 50, 60 - decoration: BoxDecoration(color: Colors.black.withAlpha(100), shape: BoxShape.circle), 61 - child: const Icon(FluentIcons.play_24_filled, color: Colors.white, size: 32), 62 - ), 63 - Positioned( 64 - top: 8, 65 - left: 0, 66 - right: 0, 67 - child: Container( 68 - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), 69 - decoration: BoxDecoration(color: Colors.black.withAlpha(100), borderRadius: BorderRadius.circular(4)), 70 - child: const Text( 71 - 'Tap to play • Hold for fullscreen', 72 - style: TextStyle(color: Colors.white, fontSize: 10), 73 - textAlign: TextAlign.center, 74 - ), 75 - ), 76 - ), 77 - ], 78 - ), 79 - ), 80 - ); 81 - } 82 - }
+14 -9
lib/src/features/profile/providers/profile_feed_provider.dart
··· 17 17 18 18 @riverpod 19 19 class ProfileFeed extends _$ProfileFeed { 20 - late final FeedRepository _feedRepository; 21 - late final SQLCacheInterface _sqlCache; 22 - late final SettingsRepository _settingsRepository; 23 - late final SparkLogger _logger; 20 + final FeedRepository _feedRepository = GetIt.instance<SprkRepository>().feed; 21 + final SQLCacheInterface _sqlCache = GetIt.instance<SQLCacheInterface>(); 22 + final SettingsRepository _settingsRepository = GetIt.instance<SettingsRepository>(); 23 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ProfileFeed'); 24 24 bool _isLoading = false; 25 25 26 26 @override 27 27 Future<ProfileFeedState> build(AtUri profileUri, bool videosOnly) async { 28 - _feedRepository = GetIt.instance<SprkRepository>().feed; 29 - _sqlCache = GetIt.instance<SQLCacheInterface>(); 30 - _settingsRepository = GetIt.instance<SettingsRepository>(); 31 - _logger = GetIt.instance<LogService>().getLogger('ProfileFeed $profileUri'); 32 - 33 28 try { 34 29 final result = await _loadUnifiedFeed( 35 30 profileUri: profileUri, ··· 289 284 } 290 285 291 286 return filteredUris; 287 + } 288 + 289 + Future<void> deletePost(AtUri postUri) async { 290 + try { 291 + await GetIt.I<SQLCacheInterface>().deletePost(postUri); 292 + await GetIt.I<SprkRepository>().repo.deleteRecord(uri: postUri); 293 + ref.invalidateSelf(); 294 + } catch (e) { 295 + throw Exception('Failed to delete post: $e'); 296 + } 292 297 } 293 298 }
+90 -4
lib/src/features/profile/ui/widgets/profile_header.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:flutter_svg/flutter_svg.dart'; 5 6 import 'package:get_it/get_it.dart'; 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:imgly_editor/model/source.dart'; 6 9 import 'package:sparksocial/src/core/auth/data/repositories/identity_repository.dart'; 10 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 7 11 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart' as actor_models; 8 12 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 13 import 'package:sparksocial/src/core/routing/app_router.dart'; ··· 12 16 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 13 17 import 'package:sparksocial/src/core/utils/text_formatter.dart'; 14 18 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 19 + import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 15 20 import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 16 21 import 'package:sparksocial/src/features/profile/ui/widgets/profile_description.dart'; 17 22 import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; 18 23 import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; 19 24 20 - class ProfileHeader extends StatefulWidget { 25 + class ProfileHeader extends ConsumerStatefulWidget { 21 26 const ProfileHeader({ 22 27 required this.profile, 23 28 required this.isCurrentUser, ··· 37 42 final VoidCallback onFollowTap; 38 43 39 44 @override 40 - State<ProfileHeader> createState() => _ProfileHeaderState(); 45 + ConsumerState<ProfileHeader> createState() => _ProfileHeaderState(); 41 46 } 42 47 43 - class _ProfileHeaderState extends State<ProfileHeader> { 48 + class _ProfileHeaderState extends ConsumerState<ProfileHeader> { 44 49 late final SparkLogger _logger; 45 50 late final IdentityRepository _identityRepository; 46 51 late final SprkRepository _sprkRepository; ··· 192 197 right: 0, 193 198 bottom: 0, 194 199 child: GestureDetector( 195 - onTap: () => context.router.push(CreateVideoRoute(isStoryMode: true)), 200 + onTap: () async { 201 + final colorScheme = Theme.of(context).colorScheme; 202 + final imglyRepository = GetIt.I<IMGLYRepository>(); 203 + final handle = ref.read(sessionProvider)?.handle; 204 + showModalBottomSheet( 205 + context: context, 206 + backgroundColor: Colors.transparent, 207 + builder: (BuildContext context) { 208 + return SafeArea( 209 + child: Container( 210 + padding: const EdgeInsets.all(16), 211 + decoration: BoxDecoration( 212 + color: colorScheme.surface, 213 + borderRadius: const BorderRadius.only( 214 + topLeft: Radius.circular(20), 215 + topRight: Radius.circular(20), 216 + ), 217 + ), 218 + child: Wrap( 219 + children: <Widget>[ 220 + ListTile( 221 + leading: Icon(Icons.camera_alt, color: colorScheme.onSurface), 222 + title: Text('Record', style: TextStyle(color: colorScheme.onSurface)), 223 + onTap: () async { 224 + // camera -> open editor -> video review page -> post page 225 + final cameraResult = await imglyRepository.openCamera(userID: handle); 226 + if (cameraResult != null && 227 + cameraResult.recording != null && 228 + cameraResult.recording!.recordings.isNotEmpty) { 229 + if (context.mounted) { 230 + final video = await imglyRepository.openVideoEditor( 231 + source: Source.fromVideo(cameraResult.recording!.recordings.first.videos.first.uri), 232 + ); 233 + if (video != null && context.mounted) { 234 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: true)); 235 + } 236 + } 237 + } 238 + }, 239 + ), 240 + ListTile( 241 + leading: Icon(Icons.videocam, color: colorScheme.onSurface), 242 + title: Text('Upload Video', style: TextStyle(color: colorScheme.onSurface)), 243 + onTap: () async { 244 + // pick video -> open editor -> video review page -> post page 245 + final pickedVideo = await ImagePicker().pickVideo( 246 + source: ImageSource.gallery, 247 + maxDuration: const Duration(seconds: 180), 248 + ); 249 + if (pickedVideo != null && context.mounted) { 250 + final video = await imglyRepository.openVideoEditor( 251 + source: Source.fromVideo('file://${pickedVideo.path}'), 252 + ); 253 + if (video != null && context.mounted) { 254 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: true)); 255 + } 256 + } 257 + }, 258 + ), 259 + ListTile( 260 + leading: Icon(Icons.photo_library, color: colorScheme.onSurface), 261 + title: Text('Upload Images', style: TextStyle(color: colorScheme.onSurface)), 262 + onTap: () async { 263 + // pick images -> images review page (image editor when image is selected) -> post page 264 + final pickedImages = await ImagePicker().pickMultiImage(limit: 12); 265 + if (context.mounted && pickedImages.isNotEmpty) { 266 + context.router.push( 267 + ImageReviewRoute( 268 + imageFiles: pickedImages, 269 + storyMode: true, 270 + ), 271 + ); 272 + } 273 + }, 274 + ), 275 + ], 276 + ), 277 + ), 278 + ); 279 + }, 280 + ); 281 + }, 196 282 child: Container( 197 283 width: 30, 198 284 height: 30,
+86 -3
lib/src/features/stories/ui/widgets/stories_list.dart
··· 3 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:get_it/get_it.dart'; 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:imgly_editor/model/source.dart'; 9 + import 'package:sparksocial/src/core/imgly/imgly_repository.dart'; 6 10 import 'package:sparksocial/src/core/routing/app_router.dart'; 7 11 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 8 12 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; ··· 19 23 class _StoriesListState extends ConsumerState<StoriesList> { 20 24 String? _cursor; 21 25 26 + void _showCreateMenu(BuildContext context) { 27 + final colorScheme = Theme.of(context).colorScheme; 28 + final imglyRepository = GetIt.I<IMGLYRepository>(); 29 + final handle = ref.read(sessionProvider)?.handle; 30 + showModalBottomSheet( 31 + context: context, 32 + backgroundColor: Colors.transparent, 33 + builder: (BuildContext context) { 34 + return SafeArea( 35 + child: Container( 36 + padding: const EdgeInsets.all(16), 37 + decoration: BoxDecoration( 38 + color: colorScheme.surface, 39 + borderRadius: const BorderRadius.only( 40 + topLeft: Radius.circular(20), 41 + topRight: Radius.circular(20), 42 + ), 43 + ), 44 + child: Wrap( 45 + children: <Widget>[ 46 + ListTile( 47 + leading: Icon(Icons.camera_alt, color: colorScheme.onSurface), 48 + title: Text('Record', style: TextStyle(color: colorScheme.onSurface)), 49 + onTap: () async { 50 + // camera -> open editor -> video review page -> post page 51 + final cameraResult = await imglyRepository.openCamera(userID: handle); 52 + if (cameraResult != null && cameraResult.recording != null && cameraResult.recording!.recordings.isNotEmpty) { 53 + if (context.mounted) { 54 + final video = await imglyRepository.openVideoEditor( 55 + source: Source.fromVideo(cameraResult.recording!.recordings.first.videos.first.uri), 56 + ); 57 + if (video != null && context.mounted) { 58 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: true)); 59 + } 60 + } 61 + } 62 + }, 63 + ), 64 + ListTile( 65 + leading: Icon(Icons.videocam, color: colorScheme.onSurface), 66 + title: Text('Upload Video', style: TextStyle(color: colorScheme.onSurface)), 67 + onTap: () async { 68 + // pick video -> open editor -> video review page -> post page 69 + final pickedVideo = await ImagePicker().pickVideo( 70 + source: ImageSource.gallery, 71 + maxDuration: const Duration(seconds: 180), 72 + ); 73 + if (pickedVideo != null && context.mounted) { 74 + final video = await imglyRepository.openVideoEditor( 75 + source: Source.fromVideo('file://${pickedVideo.path}'), 76 + ); 77 + if (video != null && context.mounted) { 78 + context.router.push(VideoReviewRoute(editorResult: video, storyMode: true)); 79 + } 80 + } 81 + }, 82 + ), 83 + ListTile( 84 + leading: Icon(Icons.photo_library, color: colorScheme.onSurface), 85 + title: Text('Upload Images', style: TextStyle(color: colorScheme.onSurface)), 86 + onTap: () async { 87 + // pick images -> images review page (image editor when image is selected) -> post page 88 + final pickedImages = await ImagePicker().pickMultiImage(limit: 12); 89 + if (context.mounted && pickedImages.isNotEmpty) { 90 + context.router.push( 91 + ImageReviewRoute( 92 + imageFiles: pickedImages, 93 + storyMode: true, 94 + ), 95 + ); 96 + } 97 + }, 98 + ), 99 + ], 100 + ), 101 + ), 102 + ); 103 + }, 104 + ); 105 + } 106 + 22 107 @override 23 108 Widget build(BuildContext context) { 24 109 final storiesByAuthor = ref.watch(storiesByAuthorProvider(cursor: _cursor)); ··· 56 141 child: Column( 57 142 children: [ 58 143 GestureDetector( 59 - onTap: () => {context.router.push(CreateVideoRoute(isStoryMode: true))}, 144 + onTap: () => _showCreateMenu(context), 60 145 child: Stack( 61 146 children: [ 62 147 Container( ··· 112 197 ), 113 198 ); 114 199 } 115 - 116 200 final realIndex = index - 1; 117 201 final authorEntry = authorsList[realIndex]; 118 202 final author = authorEntry.key; 119 - 120 203 return Container( 121 204 margin: const EdgeInsets.only(right: 12), 122 205 child: Column(
+10 -2
pubspec.lock
··· 808 808 url: "https://pub.dev" 809 809 source: hosted 810 810 version: "0.2.1+1" 811 + imgly_camera: 812 + dependency: "direct main" 813 + description: 814 + name: imgly_camera 815 + sha256: "8615ee58667550dce32c36196bf6917f54ba57d75f0c864ced937f22f16dce56" 816 + url: "https://pub.dev" 817 + source: hosted 818 + version: "1.53.0" 811 819 imgly_editor: 812 820 dependency: "direct main" 813 821 description: 814 822 name: imgly_editor 815 - sha256: "290d9e5f4f7308a4a03376a9df53fe8f5ae375df83879bf1f36a49334087896f" 823 + sha256: "73f3685371e533e785ae27d873c4865b4ec13d610ff055091f4effe91d1a9cf9" 816 824 url: "https://pub.dev" 817 825 source: hosted 818 - version: "1.51.0" 826 + version: "1.53.0" 819 827 io: 820 828 dependency: transitive 821 829 description:
+2 -1
pubspec.yaml
··· 42 42 collection: ^1.19.1 43 43 carousel_slider: ^5.0.0 44 44 smooth_video_progress: ^0.0.4 45 - imgly_editor: ^1.51.0 45 + imgly_editor: ^1.53.0 46 + imgly_camera: ^1.53.0 46 47 web_socket_channel: ^3.0.3 47 48 any_link_preview: ^3.0.3 48 49