diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index 5a59c201..76e9491c 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -23,6 +23,7 @@ import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/logging/logger.dart'; import 'package:spark/src/core/utils/share_urls.dart'; +import 'package:spark/src/core/utils/video_upload_exception.dart'; /// Implementation of Feed-related API endpoints class FeedRepositoryImpl implements FeedRepository { @@ -1194,12 +1195,27 @@ class FeedRepositoryImpl implements FeedRepository { } // Check if the video is in a compatible format - final videoBytes = await file.readAsBytes(); - if (videoBytes.isEmpty) { + final videoSizeBytes = await file.length(); + if (videoSizeBytes == 0) { throw Exception('Video file is empty'); } - _logger.i('Video file size: ${videoBytes.length} bytes'); + _logger.i('Video file size: $videoSizeBytes bytes'); + final maxUploadSizeBytes = (AppConfig.maxUploadSizeMB * 1024 * 1024) + .round(); + if (maxUploadSizeBytes > 0 && videoSizeBytes > maxUploadSizeBytes) { + _logger.w( + 'Video file exceeds upload limit: $videoSizeBytes bytes ' + '(limit: $maxUploadSizeBytes bytes)', + ); + throw VideoUploadException( + 'Video is too large to upload.', + statusCode: 413, + uploadSizeBytes: videoSizeBytes, + limitBytes: maxUploadSizeBytes, + ); + } + final videoBytes = await file.readAsBytes(); final pdsService = authAtProto.service; final serviceTokenRes = await authAtProto.server.getServiceAuth( @@ -1226,8 +1242,17 @@ class FeedRepositoryImpl implements FeedRepository { ); if (response.statusCode != 200) { - throw Exception( - 'Failed to upload video: ${response.statusCode} ${response.body}', + _logger.e( + 'Video upload failed: ${response.statusCode} ${response.body}', + ); + throw VideoUploadException( + response.statusCode == 413 + ? 'Video is too large to upload.' + : 'Failed to upload video.', + statusCode: response.statusCode, + uploadSizeBytes: videoSizeBytes, + limitBytes: maxUploadSizeBytes > 0 ? maxUploadSizeBytes : null, + responseBody: response.body, ); } diff --git a/lib/src/core/utils/error_messages.dart b/lib/src/core/utils/error_messages.dart index 15404b4a..4a0c8291 100644 --- a/lib/src/core/utils/error_messages.dart +++ b/lib/src/core/utils/error_messages.dart @@ -1,3 +1,5 @@ +import 'package:spark/src/core/utils/video_upload_exception.dart'; + /// Utility for converting exceptions into user-friendly error messages. /// This prevents exposing internal implementation details to users while still /// providing helpful feedback. @@ -8,8 +10,29 @@ class ErrorMessages { return 'An unexpected error occurred'; } + if (error is VideoUploadException) { + if (error.isPayloadTooLarge) { + final uploadSize = error.uploadSizeBytes; + final limit = error.limitBytes; + if (uploadSize != null && limit != null) { + return 'This video is too large to upload ' + '(${_formatBytes(uploadSize)}). Please trim or compress it under ' + '${_formatBytes(limit)} and try again.'; + } + return 'This video is too large to upload. Please trim or compress it and try again.'; + } + return 'Unable to upload video. Please try again'; + } + final errorStr = error.toString().toLowerCase(); + // Upload size errors + if (errorStr.contains('413') || + errorStr.contains('payload too large') || + errorStr.contains('too large')) { + return 'This file is too large to upload. Please trim or compress it and try again.'; + } + // Network errors if (errorStr.contains('socketexception') || errorStr.contains('network') || @@ -112,4 +135,20 @@ class ErrorMessages { return baseMessage; } + + static String _formatBytes(int bytes) { + const mb = 1024 * 1024; + if (bytes >= mb) { + final value = bytes / mb; + final formatted = value >= 10 + ? value.toStringAsFixed(0) + : value.toStringAsFixed(1); + return '$formatted MB'; + } + const kb = 1024; + if (bytes >= kb) { + return '${(bytes / kb).toStringAsFixed(0)} KB'; + } + return '$bytes B'; + } } diff --git a/lib/src/core/utils/video_upload_exception.dart b/lib/src/core/utils/video_upload_exception.dart new file mode 100644 index 00000000..46fb03d3 --- /dev/null +++ b/lib/src/core/utils/video_upload_exception.dart @@ -0,0 +1,25 @@ +/// Error raised when the video processing service rejects an upload. +class VideoUploadException implements Exception { + const VideoUploadException( + this.message, { + this.statusCode, + this.uploadSizeBytes, + this.limitBytes, + this.responseBody, + }); + + final String message; + final int? statusCode; + final int? uploadSizeBytes; + final int? limitBytes; + final String? responseBody; + + bool get isPayloadTooLarge => + statusCode == 413 || + (uploadSizeBytes != null && + limitBytes != null && + uploadSizeBytes! > limitBytes!); + + @override + String toString() => message; +} diff --git a/lib/src/features/posting/providers/video_upload_provider.dart b/lib/src/features/posting/providers/video_upload_provider.dart index 847a812a..2915e83f 100644 --- a/lib/src/features/posting/providers/video_upload_provider.dart +++ b/lib/src/features/posting/providers/video_upload_provider.dart @@ -33,7 +33,7 @@ Future processVideo(Ref ref, String videoPath) async { error: error, stackTrace: stackTrace, ); - return null; + rethrow; } } @@ -95,8 +95,8 @@ Future postVideo( return finalResult; } catch (error, stackTrace) { logger.e('Error posting video', error: error, stackTrace: stackTrace); + rethrow; } - return null; } /// Process video and post it in one step diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index d2a6fd66..8f772138 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -10,6 +10,7 @@ import 'package:spark/src/core/design_system/templates/recording_page_template.d import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/error_messages.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/posting/providers/camera_provider.dart'; import 'package:spark/src/features/posting/providers/recording_provider.dart'; @@ -240,7 +241,11 @@ class _RecordingPageState extends ConsumerState { _isExiting = false; }); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to post story: $e')), + SnackBar( + content: Text( + ErrorMessages.getOperationErrorMessage('post', e), + ), + ), ); } } @@ -373,9 +378,13 @@ class _RecordingPageState extends ConsumerState { stackTrace: stackTrace, ); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to post story: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ErrorMessages.getOperationErrorMessage('post', e), + ), + ), + ); } } // If posting failed or was cancelled, reset state diff --git a/lib/src/features/posting/ui/pages/story_post_page.dart b/lib/src/features/posting/ui/pages/story_post_page.dart index 96145ee8..426eb6b8 100644 --- a/lib/src/features/posting/ui/pages/story_post_page.dart +++ b/lib/src/features/posting/ui/pages/story_post_page.dart @@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/network/atproto/data/models/models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/core/utils/error_messages.dart'; import 'package:spark/src/features/posting/providers/post_story.dart'; import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; @@ -72,7 +73,7 @@ class _StoryPostPageState extends ConsumerState { if (mounted) { setState(() { _isPosting = false; - _error = e.toString(); + _error = ErrorMessages.getOperationErrorMessage('post', e); }); } } diff --git a/lib/src/features/posting/ui/pages/video_review_page.dart b/lib/src/features/posting/ui/pages/video_review_page.dart index c5ef4626..49a530b2 100644 --- a/lib/src/features/posting/ui/pages/video_review_page.dart +++ b/lib/src/features/posting/ui/pages/video_review_page.dart @@ -10,6 +10,7 @@ import 'package:spark/src/core/design_system/templates/video_review_page_templat import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; +import 'package:spark/src/core/utils/error_messages.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; @@ -106,42 +107,48 @@ class _VideoReviewPageState extends ConsumerState { ).future, ); + if (!mounted) return; setState(() { _isPosting = false; }); - if (mounted) { - context.router.popUntilRoot(); - final did = ref.read(currentDidProvider); - if (did != null) { - ref - ..invalidate( - profileFeedProvider(AtUri.parse('at://$did'), false, false), - ) - ..invalidate( - profileFeedProvider(AtUri.parse('at://$did'), true, false), - ); - } - if (postRef == null) { - return; - } else { - if (!widget.storyMode) { - context.router.push( - StandalonePostRoute(postUri: postRef.uri.toString()), - ); - } - } + if (postRef == null) { + _showPostError('Unable to create post. Please try again'); + return; } - } catch (e) { - if (mounted) { - setState(() { - _isPosting = false; - }); + + final did = ref.read(currentDidProvider); + if (did != null) { + ref + ..invalidate( + profileFeedProvider(AtUri.parse('at://$did'), false, false), + ) + ..invalidate( + profileFeedProvider(AtUri.parse('at://$did'), true, false), + ); } + + final router = context.router; + router.popUntilRoot(); + if (!widget.storyMode) { + router.push(StandalonePostRoute(postUri: postRef.uri.toString())); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isPosting = false; + }); + _showPostError(ErrorMessages.getOperationErrorMessage('post', e)); } return; } + void _showPostError(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + @override Widget build(BuildContext context) { final rawAspectRatio = _player?.value.aspectRatio; diff --git a/test/src/core/utils/error_messages_test.dart b/test/src/core/utils/error_messages_test.dart new file mode 100644 index 00000000..79e502de --- /dev/null +++ b/test/src/core/utils/error_messages_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/utils/error_messages.dart'; +import 'package:spark/src/core/utils/video_upload_exception.dart'; + +void main() { + group('ErrorMessages', () { + test('describes oversized video uploads with sizes', () { + const error = VideoUploadException( + 'Video is too large to upload.', + statusCode: 413, + uploadSizeBytes: 120 * 1024 * 1024, + limitBytes: 100 * 1024 * 1024, + ); + + final message = ErrorMessages.getOperationErrorMessage('post', error); + + expect(message, contains('This video is too large to upload')); + expect(message, contains('120 MB')); + expect(message, contains('under 100 MB')); + }); + + test('maps raw 413 payload errors to a safe upload message', () { + final message = ErrorMessages.getOperationErrorMessage( + 'post', + Exception('Failed to upload video: 413 Payload Too Large'), + ); + + expect( + message, + 'This file is too large to upload. Please trim or compress it and try again.', + ); + }); + }); +}