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

fix(stories): weird resolution and scaling issue

+165 -99
+4 -2
lib/src/core/design_system/components/molecules/feed_tag_list.dart
··· 102 102 }); 103 103 }, 104 104 onReorder: (oldIndex, newIndex) { 105 - if (newIndex > oldIndex) newIndex -= 1; 106 - widget.onReorder?.call(oldIndex, newIndex); 105 + final adjustedNewIndex = newIndex > oldIndex 106 + ? newIndex - 1 107 + : newIndex; 108 + widget.onReorder?.call(oldIndex, adjustedNewIndex); 107 109 }, 108 110 proxyDecorator: (child, index, animation) { 109 111 return AnimatedBuilder(
+48 -41
lib/src/core/design_system/templates/recording_page_template.dart
··· 46 46 47 47 @override 48 48 Widget build(BuildContext context) { 49 - final size = MediaQuery.sizeOf(context); 50 49 const footerHeight = kBottomNavigationBarHeight + 12; 51 50 const borderRadius = BorderRadius.all(Radius.circular(20)); 52 51 53 - // Calculate scale based on camera aspect ratio and screen aspect ratio 54 - var scale = size.aspectRatio * aspectRatio; 55 - 56 - // To prevent scaling down, invert the value if scale < 1 57 - if (scale < 1) scale = 1 / scale; 58 - 59 52 return Scaffold( 60 53 backgroundColor: Colors.black, 61 54 body: SafeArea( 62 55 child: Column( 63 56 children: [ 64 57 Expanded( 65 - child: ClipRRect( 66 - borderRadius: borderRadius, 67 - child: Stack( 68 - fit: StackFit.expand, 69 - children: [ 70 - // Camera preview fills rounded view area 71 - Positioned.fill( 72 - child: Transform.scale( 73 - scale: scale, 74 - child: Center( 75 - child: cameraPreview, 58 + child: LayoutBuilder( 59 + builder: (context, constraints) { 60 + final viewportSize = Size( 61 + constraints.maxWidth, 62 + constraints.maxHeight, 63 + ); 64 + 65 + // Calculate scale against the real preview viewport instead 66 + // of the full screen to avoid over-zooming. 67 + var scale = viewportSize.aspectRatio * aspectRatio; 68 + if (scale < 1) scale = 1 / scale; 69 + 70 + return ClipRRect( 71 + borderRadius: borderRadius, 72 + child: Stack( 73 + fit: StackFit.expand, 74 + children: [ 75 + // Camera preview fills rounded view area 76 + Positioned.fill( 77 + child: Transform.scale( 78 + scale: scale, 79 + child: Center( 80 + child: cameraPreview, 81 + ), 82 + ), 83 + ), 84 + // Top controls aligned within rounded view 85 + _TopOverlay( 86 + onBack: onBack, 87 + timer: RecordingTimer( 88 + duration: elapsedDuration, 89 + maxDuration: maxDuration, 90 + ), 91 + ), 92 + // Bottom overlay sits inside rounded view 93 + _BottomOverlay( 94 + onFlipCamera: canFlipCamera ? onFlipCamera : null, 95 + recordingButton: RecordingButton( 96 + isRecording: isRecording, 97 + mode: captureMode, 98 + onTap: onTap, 99 + onRecordStart: onRecordStart, 100 + onRecordStop: onRecordStop, 101 + ), 102 + bottomPadding: 24, 76 103 ), 77 - ), 78 - ), 79 - // Top controls aligned within rounded view 80 - _TopOverlay( 81 - onBack: onBack, 82 - timer: RecordingTimer( 83 - duration: elapsedDuration, 84 - maxDuration: maxDuration, 85 - ), 86 - ), 87 - // Bottom overlay sits inside rounded view 88 - _BottomOverlay( 89 - onFlipCamera: canFlipCamera ? onFlipCamera : null, 90 - recordingButton: RecordingButton( 91 - isRecording: isRecording, 92 - mode: captureMode, 93 - onTap: onTap, 94 - onRecordStart: onRecordStart, 95 - onRecordStop: onRecordStop, 96 - ), 97 - bottomPadding: 24, 104 + ], 98 105 ), 99 - ], 100 - ), 106 + ); 107 + }, 101 108 ), 102 109 ), 103 110 const SizedBox(
+2 -2
lib/src/core/pro_image_editor/story_image_editor_configs.dart
··· 27 27 class StoryImageEditorConfigs { 28 28 const StoryImageEditorConfigs._(); 29 29 30 - /// Fixed story canvas size (1080x1920 = 9:16 aspect ratio). 31 - static const Size storySize = Size(1080, 1920); 30 + /// Fixed story canvas size (1440x2560 = 9:16 aspect ratio). 31 + static const Size storySize = Size(1440, 2560); 32 32 33 33 /// Builds the ProImageEditor configuration for story editing. 34 34 ///
+97 -42
lib/src/core/pro_image_editor/ui/story_image_editor_page.dart
··· 12 12 13 13 /// A story-specific image editor page. 14 14 /// 15 - /// This editor uses a fixed 9:16 aspect ratio canvas (1080x1920) optimized 15 + /// This editor uses a fixed 9:16 aspect ratio canvas optimized 16 16 /// for Instagram Stories-style content. 17 17 /// 18 18 /// Features: ··· 240 240 final bool _useMaterialDesign = 241 241 platformDesignMode == ImageEditorDesignMode.material; 242 242 243 + static const _storyBackgroundWidgetLayerId = 'story-background-image'; 244 + 243 245 late ProImageEditorConfigs _configs; 244 246 bool _isInitialized = false; 245 - Size? _canvasSize; 246 247 Size? _imageSize; 247 - bool _hasAddedBackgroundLayer = false; 248 + ImportStateHistory? _initialStateHistory; 248 249 249 250 @override 250 251 void initState() { 251 252 super.initState(); 252 - _loadBackgroundImageSize(); 253 253 _initializeEditor(); 254 254 } 255 255 256 - Future<void> _loadBackgroundImageSize() async { 257 - if (widget.backgroundImage == null) return; 256 + Future<Size?> _readImageSize(File imageFile) async { 258 257 try { 259 - final bytes = await widget.backgroundImage!.readAsBytes(); 258 + final bytes = await imageFile.readAsBytes(); 260 259 final completer = Completer<ui.Image>(); 261 260 ui.decodeImageFromList(bytes, completer.complete); 262 261 final image = await completer.future; 263 262 final size = Size(image.width.toDouble(), image.height.toDouble()); 264 263 image.dispose(); 265 - if (mounted) { 266 - setState(() { 267 - _imageSize = size; 268 - }); 269 - _addBackgroundImageLayer(); 270 - } 271 - } catch (_) {} 264 + return size; 265 + } catch (_) { 266 + return null; 267 + } 272 268 } 273 269 274 - void _initializeEditor() { 270 + Future<void> _initializeEditor() async { 271 + Size? imageSize; 272 + if (widget.backgroundImage != null) { 273 + imageSize = await _readImageSize(widget.backgroundImage!); 274 + } 275 + 275 276 _configs = StoryImageEditorConfigs.build( 276 277 useMaterialDesign: _useMaterialDesign, 277 278 imagePreviewBuilder: () => widget.backgroundImage != null ··· 279 280 : const SizedBox.shrink(), 280 281 ); 281 282 283 + if (!mounted) return; 284 + 282 285 setState(() { 286 + _imageSize = imageSize; 283 287 _isInitialized = true; 288 + _initialStateHistory = null; 284 289 }); 285 290 } 286 291 ··· 308 313 } 309 314 } 310 315 311 - void _addBackgroundImageLayer() { 312 - if (widget.backgroundImage == null) return; 313 - if (_hasAddedBackgroundLayer) return; 314 - if (_imageSize == null) return; 316 + double _computeInitialBackgroundScale(Size previewSize) { 317 + final imageSize = _imageSize; 318 + if (imageSize == null) return 1; 315 319 316 - final editorState = _editorKey.currentState; 317 - if (editorState == null) return; 318 - 319 - final size = _canvasSize ?? StoryImageEditorConfigs.storySize; 320 + final size = 321 + previewSize.width.isFinite && 322 + previewSize.height.isFinite && 323 + previewSize.width > 0 && 324 + previewSize.height > 0 325 + ? previewSize 326 + : StoryImageEditorConfigs.storySize; 320 327 final initWidth = _configs.stickerEditor.initWidth; 321 - final imageSize = _imageSize!; 322 - final widthForCover = size.height * (imageSize.width / imageSize.height); 328 + final sourceAspectRatio = imageSize.height > 0 329 + ? imageSize.width / imageSize.height 330 + : 1.0; 331 + final widthForCover = size.height * sourceAspectRatio; 323 332 final targetWidth = widthForCover > size.width ? widthForCover : size.width; 324 - final scale = initWidth > 0 ? targetWidth / initWidth : 1.0; 325 - // Use original image aspect (no forced 9:16 crop). 326 - // Scale fills the canvas (cover), while preserving aspect ratio. 327 - editorState.addLayer( 328 - WidgetLayer( 329 - widget: Image.file( 330 - widget.backgroundImage!, 331 - fit: BoxFit.contain, 332 - ), 333 - scale: scale, 333 + final rawScale = initWidth > 0 ? targetWidth / initWidth : 1.0; 334 + return rawScale.isFinite && rawScale > 0 ? rawScale : 1.0; 335 + } 336 + 337 + ImportStateHistory? _createInitialStateHistory(Size previewSize) { 338 + final backgroundImage = widget.backgroundImage; 339 + final imageSize = _imageSize; 340 + if (backgroundImage == null || imageSize == null) return null; 341 + 342 + final layer = WidgetLayer( 343 + widget: const SizedBox.shrink(), 344 + scale: _computeInitialBackgroundScale(previewSize), 345 + exportConfigs: WidgetLayerExportConfigs( 346 + id: _storyBackgroundWidgetLayerId, 347 + meta: {'path': backgroundImage.path}, 334 348 ), 335 349 ); 336 - _hasAddedBackgroundLayer = true; 350 + 351 + const storySize = StoryImageEditorConfigs.storySize; 352 + final historyMap = { 353 + 'version': '4.0.0', 354 + 'position': 0, 355 + 'history': [ 356 + { 357 + 'layers': [layer.toMap()], 358 + }, 359 + ], 360 + 'imgSize': { 361 + 'width': storySize.width, 362 + 'height': storySize.height, 363 + }, 364 + 'lastRenderedImgSize': { 365 + 'width': storySize.width, 366 + 'height': storySize.height, 367 + }, 368 + }; 369 + 370 + return ImportStateHistory.fromMap( 371 + historyMap, 372 + configs: ImportEditorConfigs( 373 + recalculateSizeAndPosition: false, 374 + enableInitialEmptyState: false, 375 + widgetLoader: (id, {meta}) { 376 + if (id != _storyBackgroundWidgetLayerId) { 377 + return const SizedBox.shrink(); 378 + } 379 + 380 + final path = meta?['path'] as String?; 381 + if (path == null || path.isEmpty) return const SizedBox.shrink(); 382 + 383 + return Image.file( 384 + File(path), 385 + fit: BoxFit.contain, 386 + filterQuality: FilterQuality.high, 387 + ); 388 + }, 389 + ), 390 + ); 337 391 } 338 392 339 393 @override ··· 355 409 final aspect = 356 410 StoryImageEditorConfigs.storySize.height / 357 411 StoryImageEditorConfigs.storySize.width; 358 - final canvasSize = Size(viewWidth, viewWidth * aspect); 359 - _canvasSize = canvasSize; 412 + final previewSize = Size(viewWidth, viewWidth * aspect); 413 + 414 + _initialStateHistory ??= _createInitialStateHistory(previewSize); 360 415 361 416 return ProImageEditor.blank( 362 - canvasSize, 417 + StoryImageEditorConfigs.storySize, 363 418 key: _editorKey, 364 419 callbacks: ProImageEditorCallbacks( 365 420 onImageEditingComplete: _onImageEditingComplete, 366 421 onCloseEditor: _onCloseEditor, 367 - mainEditorCallbacks: MainEditorCallbacks( 368 - onAfterViewInit: _addBackgroundImageLayer, 369 - ), 370 422 stickerEditorCallbacks: StickerEditorCallbacks( 371 423 onSearchChanged: (_) {}, 372 424 ), 373 425 ), 374 426 configs: _configs.copyWith( 427 + stateHistory: _configs.stateHistory.copyWith( 428 + initStateHistory: _initialStateHistory, 429 + ), 375 430 mainEditor: _configs.mainEditor.copyWith( 376 431 style: MainEditorStyle( 377 432 background: widget.backgroundColor,
+7 -9
lib/src/core/pro_image_editor/utils/story_image_cropper.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:path_provider/path_provider.dart'; 6 6 7 - /// Utility to crop images to story aspect ratio (9:16 / 1080x1920). 7 + /// Utility to crop images to story aspect ratio (9:16). 8 8 class StoryImageCropper { 9 9 StoryImageCropper._(); 10 10 11 - /// Target story dimensions. 12 - static const double targetWidth = 1080; 13 - static const double targetHeight = 1920; 14 - static const double targetAspectRatio = targetWidth / targetHeight; // 0.5625 11 + /// Target story aspect ratio (9:16 = 0.5625). 12 + static const double targetAspectRatio = 9 / 16; 15 13 16 14 /// Crops the given image file to 9:16 aspect ratio (center crop). 17 15 /// ··· 51 49 final recorder = ui.PictureRecorder(); 52 50 final canvas = Canvas(recorder); 53 51 54 - // Draw the cropped portion scaled to target size 52 + // Draw only the cropped portion at native pixel size. 55 53 final srcRect = Rect.fromLTWH(cropX, cropY, cropWidth, cropHeight); 56 - const dstRect = Rect.fromLTWH(0, 0, targetWidth, targetHeight); 54 + final dstRect = Rect.fromLTWH(0, 0, cropWidth, cropHeight); 57 55 58 56 canvas.drawImageRect(image, srcRect, dstRect, Paint()); 59 57 60 58 // Convert to image 61 59 final picture = recorder.endRecording(); 62 60 final croppedImage = await picture.toImage( 63 - targetWidth.toInt(), 64 - targetHeight.toInt(), 61 + cropWidth.round(), 62 + cropHeight.round(), 65 63 ); 66 64 67 65 // Encode to PNG
+7 -3
lib/src/features/settings/ui/pages/feed_list_page.dart
··· 83 83 }, 84 84 onReorder: (oldIndex, newIndex) async { 85 85 // Adjust newIndex if moving down the list 86 - if (newIndex > oldIndex) newIndex -= 1; 86 + final adjustedNewIndex = newIndex > oldIndex 87 + ? newIndex - 1 88 + : newIndex; 87 89 88 90 try { 89 91 // Get the actual indices in the full feeds list ··· 91 93 final actualOldIndex = settingsState.feeds.indexOf( 92 94 filteredFeeds[oldIndex], 93 95 ); 94 - final actualNewIndex = newIndex < filteredFeeds.length 95 - ? settingsState.feeds.indexOf(filteredFeeds[newIndex]) 96 + final actualNewIndex = adjustedNewIndex < filteredFeeds.length 97 + ? settingsState.feeds.indexOf( 98 + filteredFeeds[adjustedNewIndex], 99 + ) 96 100 : settingsState.feeds.length - 1; 97 101 98 102 await ref