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

Configure Feed

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

feat(stories): new video story flow

+554 -121
+7 -2
lib/src/core/network/atproto/data/repositories/story_repository_impl.dart
··· 185 185 List<SelfLabel>? selfLabels, 186 186 List<String>? tags, 187 187 }) async { 188 + final normalizedLabels = selfLabels == null || selfLabels.isEmpty 189 + ? null 190 + : selfLabels; 191 + final normalizedTags = tags == null || tags.isEmpty ? null : tags; 192 + 188 193 final record = StoryRecord( 189 194 createdAt: DateTime.now().toUtc(), 190 195 media: media, 191 - tags: tags, 192 - labels: selfLabels, 196 + tags: normalizedTags, 197 + labels: normalizedLabels, 193 198 ); 194 199 195 200 return _client.repo.createRecord(
+102 -17
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 + import 'dart:math' as math; 3 4 4 5 import 'package:atproto/com_atproto_repo_strongref.dart'; 5 6 import 'package:atproto/core.dart'; ··· 43 44 } 44 45 45 46 class _VideoEditorGroundedPageState extends State<VideoEditorGroundedPage> { 47 + static const _storyCanvasSize = Size(1440, 2560); 48 + 46 49 final _editorKey = GlobalKey<ProImageEditorState>(); 47 50 final bool _useMaterialDesign = 48 51 platformDesignMode == ImageEditorDesignMode.material; ··· 179 182 Future<void> _initializePlayer() async { 180 183 // Start parallel initialization 181 184 final metadataFuture = _setMetadata(); 182 - final trendingAudiosFuture = _fetchTrendingAudioTracks(); 185 + final trendingAudiosFuture = widget.storyMode 186 + ? Future.value(<AudioTrack>[]) 187 + : _fetchTrendingAudioTracks(); 183 188 final controllerFuture = createVideoPlayerControllerFromEditorVideo(_video); 184 189 185 190 // Wait for completion ··· 200 205 videoPlayerBuilder: () => VideoPlayerWidget( 201 206 controller: _videoController, 202 207 isLoadingListenable: _updateClipsNotifier, 208 + useCoverFit: widget.storyMode, 203 209 ), 204 210 videoEditorConfigs: _videoConfigs, 205 211 audioTracks: _audioTracks, ··· 216 222 .copyWith( 217 223 duration: _videoMetadata.duration, 218 224 ); 219 - WidgetsBinding.instance.addPostFrameCallback((_) { 220 - _generateThumbnails(); 221 - _extractVideoWaveform(); 222 - }); 225 + if (!widget.storyMode) { 226 + WidgetsBinding.instance.addPostFrameCallback((_) { 227 + _generateThumbnails(); 228 + _extractVideoWaveform(); 229 + }); 230 + } 223 231 224 232 await Future.wait([ 225 233 _videoController.initialize(), ··· 236 244 videoPlayer: VideoPlayerWidget( 237 245 controller: _videoController, 238 246 isLoadingListenable: _updateClipsNotifier, 247 + useCoverFit: widget.storyMode, 239 248 ), 240 - initialResolution: _videoMetadata.resolution, 249 + initialResolution: widget.storyMode 250 + ? _storyCanvasSize 251 + : _videoMetadata.resolution, 241 252 videoDuration: _videoMetadata.duration, 242 253 fileSize: _videoMetadata.fileSize, 243 254 thumbnails: _thumbnails, ··· 517 528 colorMatrixList: parameters.colorFilters, 518 529 startTime: parameters.startTime, 519 530 endTime: parameters.endTime, 520 - transform: parameters.isTransformed 521 - ? ExportTransform( 522 - width: parameters.cropWidth, 523 - height: parameters.cropHeight, 524 - rotateTurns: parameters.rotateTurns, 525 - x: parameters.cropX, 526 - y: parameters.cropY, 527 - flipX: parameters.flipX, 528 - flipY: parameters.flipY, 529 - ) 530 - : null, 531 + transform: _buildExportTransform(parameters), 532 + bitrate: _videoMetadata.bitrate, 531 533 customAudioPath: await _audioService.safeCustomAudioPath( 532 534 customAudioTrack, 533 535 ), ··· 542 544 ); 543 545 } 544 546 547 + ExportTransform? _buildExportTransform(CompleteParameters parameters) { 548 + if (!widget.storyMode) { 549 + if (!parameters.isTransformed) { 550 + return null; 551 + } 552 + 553 + return ExportTransform( 554 + width: parameters.cropWidth, 555 + height: parameters.cropHeight, 556 + rotateTurns: parameters.rotateTurns, 557 + x: parameters.cropX, 558 + y: parameters.cropY, 559 + flipX: parameters.flipX, 560 + flipY: parameters.flipY, 561 + ); 562 + } 563 + 564 + final coverCrop = _computeStoryCoverCrop(_videoMetadata.resolution); 565 + final targetWidth = _storyCanvasSize.width.round(); 566 + final targetHeight = _storyCanvasSize.height.round(); 567 + 568 + return ExportTransform( 569 + width: coverCrop.width, 570 + height: coverCrop.height, 571 + x: coverCrop.x, 572 + y: coverCrop.y, 573 + rotateTurns: parameters.rotateTurns, 574 + flipX: parameters.flipX, 575 + flipY: parameters.flipY, 576 + scaleX: targetWidth / coverCrop.width, 577 + scaleY: targetHeight / coverCrop.height, 578 + ); 579 + } 580 + 581 + _StoryCoverCrop _computeStoryCoverCrop(Size sourceSize) { 582 + final sourceWidth = math.max(1, sourceSize.width.round()); 583 + final sourceHeight = math.max(1, sourceSize.height.round()); 584 + 585 + final targetAspect = _storyCanvasSize.width / _storyCanvasSize.height; 586 + final sourceAspect = sourceWidth / sourceHeight; 587 + 588 + var cropWidth = sourceWidth; 589 + var cropHeight = sourceHeight; 590 + var cropX = 0; 591 + var cropY = 0; 592 + 593 + if ((sourceAspect - targetAspect).abs() > 0.0001) { 594 + if (sourceAspect > targetAspect) { 595 + cropWidth = (sourceHeight * targetAspect).round(); 596 + cropX = ((sourceWidth - cropWidth) / 2).round(); 597 + } else { 598 + cropHeight = (sourceWidth / targetAspect).round(); 599 + cropY = ((sourceHeight - cropHeight) / 2).round(); 600 + } 601 + } 602 + 603 + cropWidth = cropWidth.clamp(1, sourceWidth); 604 + cropHeight = cropHeight.clamp(1, sourceHeight); 605 + cropX = cropX.clamp(0, sourceWidth - cropWidth); 606 + cropY = cropY.clamp(0, sourceHeight - cropHeight); 607 + 608 + return _StoryCoverCrop( 609 + x: cropX, 610 + y: cropY, 611 + width: cropWidth, 612 + height: cropHeight, 613 + ); 614 + } 615 + 545 616 /// Closes the video editor and returns the edited video with audio metadata. 546 617 /// 547 618 /// Returns [VideoEditorResult] if [_outputPath] is available, otherwise ··· 646 717 ); 647 718 } 648 719 } 720 + 721 + class _StoryCoverCrop { 722 + const _StoryCoverCrop({ 723 + required this.x, 724 + required this.y, 725 + required this.width, 726 + required this.height, 727 + }); 728 + 729 + final int x; 730 + final int y; 731 + final int width; 732 + final int height; 733 + }
+39 -5
lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart
··· 7 7 import 'package:spark/src/core/design_system/theme/color_scheme.dart'; 8 8 import 'package:spark/src/core/design_system/theme/text_theme.dart'; 9 9 import 'package:spark/src/core/design_system/tokens/colors.dart'; 10 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart'; 11 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_header.dart'; 10 12 import 'package:spark/src/core/pro_video_editor/ui/widgets/blur/blur_editor_bar.dart'; 11 13 import 'package:spark/src/core/pro_video_editor/ui/widgets/clip/clip_editor_bar.dart'; 12 14 import 'package:spark/src/core/pro_video_editor/ui/widgets/clip/clips_editor_bar.dart'; ··· 22 24 import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; 23 25 import 'package:spark/src/core/pro_video_editor/ui/widgets/tune/tune_editor_bar.dart'; 24 26 27 + const _storyEditorBorderRadius = BorderRadius.vertical( 28 + top: Radius.circular(20), 29 + bottom: Radius.circular(20), 30 + ); 31 + 25 32 class VideoEditorConfigsBuilder { 26 33 const VideoEditorConfigsBuilder._(); 27 34 28 35 /// Tools available in story mode (matches story image editor). 29 36 static const _storyModeTools = [ 30 - SubEditorMode.audio, 31 37 SubEditorMode.paint, 32 38 SubEditorMode.text, 33 39 SubEditorMode.filter, ··· 105 111 bottomBar: (editor, rebuildStream, key) => ReactiveWidget( 106 112 key: key, 107 113 builder: (context) { 114 + if (storyMode) { 115 + return StoryEditorBottomSection(editor: editor); 116 + } 117 + 108 118 return VideoEditorBottomSection( 109 119 editor: editor, 110 120 videoTimelineState: videoTimelineState, ··· 117 127 }, 118 128 stream: rebuildStream, 119 129 ), 130 + wrapBody: (editor, rebuildStream, content) { 131 + if (!storyMode) { 132 + return content; 133 + } 134 + 135 + return ClipRRect( 136 + borderRadius: _storyEditorBorderRadius, 137 + child: Container( 138 + width: double.infinity, 139 + height: double.infinity, 140 + color: Colors.black, 141 + child: content, 142 + ), 143 + ); 144 + }, 120 145 bodyItems: (editor, rebuildStream) => [ 121 146 ReactiveWidget( 122 147 stream: rebuildStream, ··· 126 151 right: 0, 127 152 child: SafeArea( 128 153 bottom: false, 129 - child: VideoEditorHeader( 130 - onBack: editor.closeEditor, 131 - onNext: editor.doneEditing, 132 - ), 154 + child: storyMode 155 + ? StoryEditorHeader( 156 + onBack: editor.closeEditor, 157 + onDone: editor.doneEditing, 158 + canUndo: editor.canUndo, 159 + canRedo: editor.canRedo, 160 + onUndo: editor.undoAction, 161 + onRedo: editor.redoAction, 162 + ) 163 + : VideoEditorHeader( 164 + onBack: editor.closeEditor, 165 + onNext: editor.doneEditing, 166 + ), 133 167 ), 134 168 ), 135 169 ),
+32 -1
lib/src/core/pro_video_editor/ui/widgets/player/video_player_widget.dart
··· 10 10 const VideoPlayerWidget({ 11 11 required this.controller, 12 12 required this.isLoadingListenable, 13 + this.useCoverFit = false, 13 14 super.key, 14 15 }); 15 16 16 17 final VideoPlayerController controller; 17 18 final ValueListenable<bool?> isLoadingListenable; 19 + final bool useCoverFit; 18 20 19 21 @override 20 22 Widget build(BuildContext context) { 21 23 return ValueListenableBuilder<bool?>( 22 24 valueListenable: isLoadingListenable, 23 25 builder: (_, isLoading, _) { 26 + final size = controller.value.size; 27 + final width = size.width > 0 ? size.width : 1280.0; 28 + final height = size.height > 0 ? size.height : 720.0; 29 + 24 30 return Center( 25 31 child: isLoading ?? false 26 32 ? const CircularProgressIndicator.adaptive() 33 + : useCoverFit 34 + ? LayoutBuilder( 35 + builder: (context, constraints) { 36 + if (!constraints.hasBoundedWidth || 37 + !constraints.hasBoundedHeight) { 38 + return AspectRatio( 39 + aspectRatio: width / height, 40 + child: VideoPlayer(controller), 41 + ); 42 + } 43 + 44 + return SizedBox( 45 + width: constraints.maxWidth, 46 + height: constraints.maxHeight, 47 + child: FittedBox( 48 + fit: BoxFit.cover, 49 + child: SizedBox( 50 + width: width, 51 + height: height, 52 + child: VideoPlayer(controller), 53 + ), 54 + ), 55 + ); 56 + }, 57 + ) 27 58 : AspectRatio( 28 - aspectRatio: controller.value.size.aspectRatio, 59 + aspectRatio: width / height, 29 60 child: VideoPlayer(controller), 30 61 ), 31 62 );
+79 -33
lib/src/features/posting/providers/camera_provider.dart
··· 93 93 Future<void> reinitializeCamera() async { 94 94 _logger.i('Reinitializing camera'); 95 95 96 + await _disposeCamera(); 97 + if (!ref.mounted) return; 98 + 96 99 state = const AsyncValue.loading(); 97 100 98 101 try { ··· 126 129 final newIndex = 127 130 (currentState.selectedCameraIndex + 1) % currentState.cameras.length; 128 131 final newCamera = currentState.cameras[newIndex]; 132 + final oldController = currentState.controller; 129 133 130 - // Show flipping state immediately so the UI can paint a loading overlay 134 + // Detach preview first, then dispose old controller. 131 135 state = AsyncValue.data( 132 - currentState.copyWith(isFlipping: true, error: null), 136 + currentState.copyWith( 137 + controller: null, 138 + isInitialized: false, 139 + isFlipping: true, 140 + error: null, 141 + ), 133 142 ); 134 143 135 - // Defer heavy work to after the next frame so the current frame paints 136 - SchedulerBinding.instance.addPostFrameCallback((_) async { 144 + await _waitForPreviewDetach(); 145 + if (!ref.mounted) return; 146 + 147 + try { 148 + _logger.d('Switching to camera: ${newCamera.name}'); 149 + 150 + await oldController?.dispose(); 137 151 if (!ref.mounted) return; 138 - try { 139 - _logger.d('Switching to camera: ${newCamera.name}'); 140 152 141 - await currentState.controller?.dispose(); 142 - if (!ref.mounted) return; 153 + final newController = await _createCameraController(newCamera); 154 + if (!ref.mounted) { 155 + await newController.dispose(); 156 + return; 157 + } 143 158 144 - final newController = await _createCameraController(newCamera); 145 - if (!ref.mounted) return; 159 + state = AsyncValue.data( 160 + currentState.copyWith( 161 + controller: newController, 162 + selectedCameraIndex: newIndex, 163 + isInitialized: true, 164 + isFlipping: false, 165 + error: null, 166 + ), 167 + ); 146 168 169 + _logger.i('Camera flipped successfully to ${newCamera.name}'); 170 + } catch (e, stackTrace) { 171 + _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); 172 + if (ref.mounted) { 147 173 state = AsyncValue.data( 148 174 currentState.copyWith( 149 - controller: newController, 150 - selectedCameraIndex: newIndex, 151 - isInitialized: true, 175 + controller: null, 176 + isInitialized: false, 152 177 isFlipping: false, 153 - error: null, 178 + error: e.toString(), 154 179 ), 155 180 ); 156 - 157 - _logger.i('Camera flipped successfully to ${newCamera.name}'); 158 - } catch (e, stackTrace) { 159 - _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); 160 - if (ref.mounted) { 161 - state = AsyncValue.data( 162 - currentState.copyWith(isFlipping: false, error: e.toString()), 163 - ); 164 - } 165 181 } 166 - }); 182 + } 167 183 } 168 184 169 185 Future<XFile?> takePhoto() async { ··· 269 285 270 286 try { 271 287 final currentState = state.value; 272 - 273 - if (currentState?.controller != null) { 274 - if (currentState!.isRecording) { 275 - _logger.d('Stopping recording before disposal'); 276 - await stopVideoRecording(); 277 - } 278 - await currentState.controller!.dispose(); 279 - _logger.i('Camera controller disposed successfully'); 280 - } 288 + final controller = currentState?.controller; 289 + final wasRecording = currentState?.isRecording ?? false; 281 290 282 291 state = AsyncValue.data( 283 292 currentState?.copyWith( 284 293 controller: null, 285 294 isInitialized: false, 295 + isRecording: false, 296 + isFlipping: false, 286 297 ) ?? 287 298 const CameraState(), 288 299 ); 300 + 301 + if (controller != null) { 302 + await _waitForPreviewDetach(); 303 + 304 + if (wasRecording) { 305 + _logger.d('Stopping recording before disposal'); 306 + try { 307 + if (controller.value.isRecordingVideo) { 308 + await controller.stopVideoRecording(); 309 + } 310 + } catch (e, stackTrace) { 311 + _logger.e( 312 + 'Error stopping recording during disposal', 313 + error: e, 314 + stackTrace: stackTrace, 315 + ); 316 + } 317 + } 318 + 319 + await controller.dispose(); 320 + _logger.i('Camera controller disposed successfully'); 321 + } 289 322 } catch (e, stackTrace) { 290 323 _logger.e('Error disposing camera', error: e, stackTrace: stackTrace); 291 324 } 325 + } 326 + 327 + Future<void> _waitForPreviewDetach() async { 328 + if (!ref.mounted) return; 329 + 330 + final completer = Completer<void>(); 331 + SchedulerBinding.instance.addPostFrameCallback((_) { 332 + if (!completer.isCompleted) { 333 + completer.complete(); 334 + } 335 + }); 336 + SchedulerBinding.instance.scheduleFrame(); 337 + await completer.future; 292 338 } 293 339 294 340 void clearError() {
+4 -8
lib/src/features/posting/providers/video_upload_provider.dart
··· 4 4 import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 5 import 'package:spark/src/core/network/atproto/atproto.dart'; 6 6 import 'package:spark/src/core/utils/logging/log_service.dart'; 7 - import 'package:spark/src/features/posting/providers/post_story.dart'; 8 7 9 8 part 'video_upload_provider.g.dart'; 10 9 ··· 139 138 140 139 if (storyMode) { 141 140 try { 142 - final res = await ref.read( 143 - postStoryProvider( 144 - Media.video(video: videoBlob), 145 - selfLabels: [], 146 - tags: [], 147 - ).future, 141 + final storyRepository = GetIt.I<StoryRepository>(); 142 + final res = await storyRepository.postStory( 143 + Media.video(video: videoBlob), 148 144 ); 149 - logger.i('Story posted: ${res?.uri}'); 145 + logger.i('Story posted: ${res.uri}'); 150 146 return res; 151 147 } catch (e, s) { 152 148 logger.e('Failed to post story', error: e, stackTrace: s);
+212 -39
lib/src/features/posting/ui/pages/story_post_page.dart
··· 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 4 import 'package:get_it/get_it.dart'; 5 5 import 'package:image_picker/image_picker.dart'; 6 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 6 7 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 8 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 9 import 'package:spark/src/features/posting/providers/post_story.dart'; ··· 122 123 123 124 @override 124 125 Widget build(BuildContext context) { 126 + final isVideoStory = widget.videoPath != null; 127 + 125 128 return Scaffold( 126 129 backgroundColor: Colors.black, 127 - body: Center( 128 - child: Padding( 129 - padding: const EdgeInsets.all(32), 130 - child: Column( 131 - mainAxisSize: MainAxisSize.min, 130 + body: SafeArea( 131 + child: Center( 132 + child: Padding( 133 + padding: const EdgeInsets.all(24), 134 + child: ConstrainedBox( 135 + constraints: const BoxConstraints(maxWidth: 360), 136 + child: AnimatedSwitcher( 137 + duration: const Duration(milliseconds: 220), 138 + child: _error != null 139 + ? _StoryPostingErrorCard( 140 + error: _error!, 141 + onCancel: () => context.router.pop(false), 142 + onRetry: _postStory, 143 + ) 144 + : _StoryPostingProgressCard( 145 + isVideoStory: isVideoStory, 146 + message: _statusMessage, 147 + ), 148 + ), 149 + ), 150 + ), 151 + ), 152 + ), 153 + ); 154 + } 155 + } 156 + 157 + class _StoryPostingProgressCard extends StatelessWidget { 158 + const _StoryPostingProgressCard({ 159 + required this.isVideoStory, 160 + required this.message, 161 + }); 162 + 163 + final bool isVideoStory; 164 + final String message; 165 + 166 + @override 167 + Widget build(BuildContext context) { 168 + final title = isVideoStory 169 + ? 'Publishing video story' 170 + : 'Publishing photo story'; 171 + final icon = isVideoStory ? Icons.movie_creation_outlined : Icons.image; 172 + 173 + return _StoryPostingCard( 174 + key: const ValueKey('story-progress-card'), 175 + child: Column( 176 + mainAxisSize: MainAxisSize.min, 177 + children: [ 178 + Container( 179 + width: 56, 180 + height: 56, 181 + decoration: BoxDecoration( 182 + color: const Color(0x26FF2696), 183 + borderRadius: BorderRadius.circular(16), 184 + ), 185 + child: Icon(icon, color: AppColors.primary200, size: 28), 186 + ), 187 + const SizedBox(height: 18), 188 + Text( 189 + title, 190 + style: const TextStyle( 191 + color: Colors.white, 192 + fontSize: 20, 193 + fontWeight: FontWeight.w700, 194 + ), 195 + textAlign: TextAlign.center, 196 + ), 197 + const SizedBox(height: 8), 198 + Text( 199 + message, 200 + style: const TextStyle( 201 + color: Color(0xFFD7DFEC), 202 + fontSize: 14, 203 + height: 1.35, 204 + ), 205 + textAlign: TextAlign.center, 206 + ), 207 + const SizedBox(height: 18), 208 + ClipRRect( 209 + borderRadius: BorderRadius.circular(999), 210 + child: const LinearProgressIndicator( 211 + minHeight: 6, 212 + backgroundColor: Color(0x334B5563), 213 + valueColor: AlwaysStoppedAnimation(AppColors.primary500), 214 + ), 215 + ), 216 + const SizedBox(height: 10), 217 + const Text( 218 + 'Keep this screen open. This usually takes a few seconds.', 219 + style: TextStyle( 220 + color: Color(0xFF94A3B8), 221 + fontSize: 12, 222 + ), 223 + textAlign: TextAlign.center, 224 + ), 225 + ], 226 + ), 227 + ); 228 + } 229 + } 230 + 231 + class _StoryPostingErrorCard extends StatelessWidget { 232 + const _StoryPostingErrorCard({ 233 + required this.error, 234 + required this.onCancel, 235 + required this.onRetry, 236 + }); 237 + 238 + final String error; 239 + final VoidCallback onCancel; 240 + final VoidCallback onRetry; 241 + 242 + @override 243 + Widget build(BuildContext context) { 244 + return _StoryPostingCard( 245 + key: const ValueKey('story-error-card'), 246 + child: Column( 247 + mainAxisSize: MainAxisSize.min, 248 + children: [ 249 + Container( 250 + width: 56, 251 + height: 56, 252 + decoration: BoxDecoration( 253 + color: const Color(0x26EF4444), 254 + borderRadius: BorderRadius.circular(16), 255 + ), 256 + child: const Icon( 257 + Icons.error_outline, 258 + color: Color(0xFFFCA5A5), 259 + size: 28, 260 + ), 261 + ), 262 + const SizedBox(height: 18), 263 + const Text( 264 + 'Failed to post story', 265 + style: TextStyle( 266 + color: Colors.white, 267 + fontSize: 20, 268 + fontWeight: FontWeight.w700, 269 + ), 270 + textAlign: TextAlign.center, 271 + ), 272 + const SizedBox(height: 8), 273 + Text( 274 + error, 275 + style: const TextStyle( 276 + color: Color(0xFFD7DFEC), 277 + fontSize: 13, 278 + height: 1.35, 279 + ), 280 + textAlign: TextAlign.center, 281 + maxLines: 5, 282 + overflow: TextOverflow.ellipsis, 283 + ), 284 + const SizedBox(height: 20), 285 + Row( 132 286 children: [ 133 - if (_error != null) ...[ 134 - const Icon(Icons.error_outline, color: Colors.red, size: 48), 135 - const SizedBox(height: 16), 136 - const Text( 137 - 'Failed to post story', 138 - style: TextStyle(color: Colors.white, fontSize: 18), 287 + Expanded( 288 + child: OutlinedButton( 289 + onPressed: onCancel, 290 + style: OutlinedButton.styleFrom( 291 + side: const BorderSide(color: Color(0x4D94A3B8)), 292 + foregroundColor: const Color(0xFFE2E8F0), 293 + padding: const EdgeInsets.symmetric(vertical: 12), 294 + shape: RoundedRectangleBorder( 295 + borderRadius: BorderRadius.circular(12), 296 + ), 297 + ), 298 + child: const Text('Cancel'), 139 299 ), 140 - const SizedBox(height: 8), 141 - Text( 142 - _error!, 143 - style: const TextStyle(color: Colors.white70, fontSize: 14), 144 - textAlign: TextAlign.center, 145 - ), 146 - const SizedBox(height: 24), 147 - Row( 148 - mainAxisAlignment: MainAxisAlignment.center, 149 - children: [ 150 - TextButton( 151 - onPressed: () => context.router.pop(false), 152 - child: const Text('Cancel'), 300 + ), 301 + const SizedBox(width: 10), 302 + Expanded( 303 + child: ElevatedButton( 304 + onPressed: onRetry, 305 + style: ElevatedButton.styleFrom( 306 + backgroundColor: AppColors.primary600, 307 + foregroundColor: Colors.white, 308 + padding: const EdgeInsets.symmetric(vertical: 12), 309 + shape: RoundedRectangleBorder( 310 + borderRadius: BorderRadius.circular(12), 153 311 ), 154 - const SizedBox(width: 16), 155 - ElevatedButton( 156 - onPressed: _postStory, 157 - child: const Text('Retry'), 158 - ), 159 - ], 160 - ), 161 - ] else ...[ 162 - const CircularProgressIndicator(color: Colors.white), 163 - const SizedBox(height: 24), 164 - Text( 165 - _statusMessage, 166 - style: const TextStyle(color: Colors.white, fontSize: 16), 312 + ), 313 + child: const Text('Retry'), 167 314 ), 168 - ], 315 + ), 169 316 ], 170 317 ), 171 - ), 318 + ], 319 + ), 320 + ); 321 + } 322 + } 323 + 324 + class _StoryPostingCard extends StatelessWidget { 325 + const _StoryPostingCard({required this.child, super.key}); 326 + 327 + final Widget child; 328 + 329 + @override 330 + Widget build(BuildContext context) { 331 + return Container( 332 + padding: const EdgeInsets.all(22), 333 + decoration: BoxDecoration( 334 + color: const Color(0xCC0B1220), 335 + borderRadius: BorderRadius.circular(24), 336 + border: Border.all(color: const Color(0x33FFFFFF)), 337 + boxShadow: const [ 338 + BoxShadow( 339 + color: Color(0x66000000), 340 + blurRadius: 30, 341 + offset: Offset(0, 18), 342 + ), 343 + ], 172 344 ), 345 + child: child, 173 346 ); 174 347 } 175 348 }
+79 -16
lib/src/features/posting/utils/story_direct_post.dart
··· 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 4 import 'package:get_it/get_it.dart'; 5 5 import 'package:image_picker/image_picker.dart'; 6 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 6 7 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 8 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 9 import 'package:spark/src/features/posting/providers/post_story.dart'; ··· 123 124 124 125 @override 125 126 Widget build(BuildContext context) { 127 + final lowerMessage = message.toLowerCase(); 128 + final isVideo = lowerMessage.contains('video'); 129 + 126 130 return PopScope( 127 131 canPop: false, 128 - child: Center( 129 - child: Container( 130 - padding: const EdgeInsets.all(24), 131 - decoration: BoxDecoration( 132 - color: Colors.black87, 133 - borderRadius: BorderRadius.circular(16), 134 - ), 135 - child: Column( 136 - mainAxisSize: MainAxisSize.min, 137 - children: [ 138 - const CircularProgressIndicator(color: Colors.white), 139 - const SizedBox(height: 16), 140 - Text( 141 - message, 142 - style: const TextStyle(color: Colors.white), 132 + child: Material( 133 + color: Colors.black87, 134 + child: Center( 135 + child: Padding( 136 + padding: const EdgeInsets.all(28), 137 + child: ConstrainedBox( 138 + constraints: const BoxConstraints(maxWidth: 340), 139 + child: Container( 140 + padding: const EdgeInsets.all(22), 141 + decoration: BoxDecoration( 142 + color: const Color(0xCC0B1220), 143 + borderRadius: BorderRadius.circular(24), 144 + border: Border.all(color: const Color(0x33FFFFFF)), 145 + boxShadow: const [ 146 + BoxShadow( 147 + color: Color(0x66000000), 148 + blurRadius: 26, 149 + offset: Offset(0, 14), 150 + ), 151 + ], 152 + ), 153 + child: Column( 154 + mainAxisSize: MainAxisSize.min, 155 + children: [ 156 + Container( 157 + width: 56, 158 + height: 56, 159 + decoration: BoxDecoration( 160 + color: const Color(0x26FF2696), 161 + borderRadius: BorderRadius.circular(16), 162 + ), 163 + child: Icon( 164 + isVideo 165 + ? Icons.movie_creation_outlined 166 + : Icons.image_outlined, 167 + color: AppColors.primary200, 168 + size: 28, 169 + ), 170 + ), 171 + const SizedBox(height: 16), 172 + Text( 173 + isVideo 174 + ? 'Publishing video story' 175 + : 'Publishing photo story', 176 + style: const TextStyle( 177 + color: Colors.white, 178 + fontSize: 19, 179 + fontWeight: FontWeight.w700, 180 + ), 181 + textAlign: TextAlign.center, 182 + ), 183 + const SizedBox(height: 8), 184 + Text( 185 + message, 186 + style: const TextStyle( 187 + color: Color(0xFFD7DFEC), 188 + fontSize: 14, 189 + height: 1.35, 190 + ), 191 + textAlign: TextAlign.center, 192 + ), 193 + const SizedBox(height: 18), 194 + ClipRRect( 195 + borderRadius: BorderRadius.circular(999), 196 + child: const LinearProgressIndicator( 197 + minHeight: 6, 198 + backgroundColor: Color(0x334B5563), 199 + valueColor: AlwaysStoppedAnimation( 200 + AppColors.primary500, 201 + ), 202 + ), 203 + ), 204 + ], 205 + ), 143 206 ), 144 - ], 207 + ), 145 208 ), 146 209 ), 147 210 ),