[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: new share sheet

+353 -461
+1 -1
lib/src/core/design_system/components/atoms/buttons/long_button.dart
··· 37 37 borderRadius: BorderRadius.circular(8), 38 38 child: Container( 39 39 // height: 40, 40 - padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 11), 40 + padding: const EdgeInsets.symmetric(horizontal: 11), 41 41 decoration: BoxDecoration( 42 42 color: isPrimary 43 43 ? AppColors.primary600
+1
lib/src/core/design_system/components/molecules/feed_card.dart
··· 261 261 return ClipRRect( 262 262 borderRadius: BorderRadius.circular(8), 263 263 child: CachedNetworkImage( 264 + fadeInDuration: Duration.zero, 264 265 imageUrl: imageUrl, 265 266 width: 36, 266 267 height: 36,
+2 -2
lib/src/core/design_system/components/molecules/post_tile.dart
··· 59 59 ImageFiltered( 60 60 imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), 61 61 child: CachedNetworkImage( 62 + fadeInDuration: Duration.zero, 62 63 imageUrl: thumbnailUrl, 63 64 fit: BoxFit.cover, 64 - fadeInDuration: const Duration(milliseconds: 150), 65 65 placeholder: (context, url) => const ColoredBox( 66 66 color: AppColors.grey800, 67 67 ), ··· 76 76 ) 77 77 else 78 78 CachedNetworkImage( 79 + fadeInDuration: Duration.zero, 79 80 imageUrl: thumbnailUrl, 80 81 fit: BoxFit.cover, 81 - fadeInDuration: const Duration(milliseconds: 150), 82 82 placeholder: (context, url) => const ColoredBox( 83 83 color: AppColors.grey800, 84 84 ),
+1
lib/src/core/design_system/components/molecules/profile_avatar.dart
··· 33 33 if (avatarUrl != null && avatarUrl!.isNotEmpty) { 34 34 avatarWidget = ClipOval( 35 35 child: CachedNetworkImage( 36 + fadeInDuration: Duration.zero, 36 37 imageUrl: avatarUrl!, 37 38 width: size, 38 39 height: size,
+1
lib/src/core/design_system/components/molecules/profile_card.dart
··· 95 95 borderRadius: BorderRadius.circular(100), 96 96 child: imageUrl.isNotEmpty 97 97 ? CachedNetworkImage( 98 + fadeInDuration: Duration.zero, 98 99 imageUrl: imageUrl, 99 100 width: 36, 100 101 height: 36,
+1
lib/src/core/design_system/components/molecules/story_circle.dart
··· 116 116 child: ClipOval( 117 117 child: imageUrl.isNotEmpty 118 118 ? CachedNetworkImage( 119 + fadeInDuration: Duration.zero, 119 120 imageUrl: imageUrl, 120 121 width: _imageSize, 121 122 height: _imageSize,
+1
lib/src/core/pro_video_editor/ui/widgets/timeline/scrollable_timeline.dart
··· 427 427 ClipRRect( 428 428 borderRadius: BorderRadius.circular(12), 429 429 child: CachedNetworkImage( 430 + fadeInDuration: Duration.zero, 430 431 imageUrl: videoTimelineState.authorAvatarUrl!, 431 432 width: 24, 432 433 height: 24,
+4 -1
lib/src/core/storage/cache/download_manager_impl.dart
··· 219 219 videoFormat: BetterPlayerVideoFormat.hls, 220 220 videoExtension: 'm3u8', 221 221 task.post.videoUrl, 222 - placeholder: CachedNetworkImage(imageUrl: thumbnail.toString()), 222 + placeholder: CachedNetworkImage( 223 + fadeInDuration: Duration.zero, 224 + imageUrl: thumbnail.toString(), 225 + ), 223 226 cacheConfiguration: BetterPlayerCacheConfiguration( 224 227 useCache: true, 225 228 preCacheSize: 10 * 1024 * 1024, // 10 MB
+1
lib/src/core/ui/widgets/image_content.dart
··· 63 63 fit: StackFit.expand, 64 64 children: [ 65 65 CachedNetworkImage( 66 + fadeInDuration: Duration.zero, 66 67 imageUrl: imageUrls.first, 67 68 fit: BoxFit.cover, 68 69 placeholder: (context, url) => Container(
+1
lib/src/core/ui/widgets/user_avatar.dart
··· 74 74 ), 75 75 clipBehavior: Clip.antiAlias, 76 76 child: CachedNetworkImage( 77 + fadeInDuration: Duration.zero, 77 78 imageUrl: imageUrl, 78 79 fit: BoxFit.cover, 79 80 placeholder: (context, url) => ColoredBox(
+339 -365
lib/src/features/feed/ui/widgets/action_buttons/share_panel.dart
··· 3 3 import 'package:flutter/services.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:get_it/get_it.dart'; 6 + import 'package:share_plus/share_plus.dart'; 7 + import 'package:skeletonizer/skeletonizer.dart'; 8 + import 'package:spark/src/core/design_system/components/atoms/buttons/long_button.dart'; 6 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 7 10 import 'package:spark/src/features/messages/providers/conversation_provider.dart'; 8 11 import 'package:spark/src/features/messages/providers/conversations_provider.dart'; 9 12 10 13 class SharePanel extends ConsumerStatefulWidget { 11 - const SharePanel({ 12 - required this.shareUrl, 13 - required this.embedCode, 14 - required this.atUri, 15 - super.key, 16 - this.showEmbed = true, 17 - }); 14 + const SharePanel({required this.shareUrl, required this.atUri, super.key}); 15 + 18 16 final String shareUrl; 19 - final String embedCode; 20 17 final String atUri; 21 - final bool showEmbed; 22 18 23 19 @override 24 20 ConsumerState<SharePanel> createState() => _SharePanelState(); 25 21 } 26 22 27 23 class _SharePanelState extends ConsumerState<SharePanel> { 24 + static const _actionButtonHeight = 40.0; 25 + static const _motionDuration = Duration(milliseconds: 300); 26 + static const _motionCurve = Curves.easeOutCubic; 27 + 28 28 bool _copiedLink = false; 29 - bool _copiedEmbed = false; 30 29 String? _selectedConvoId; 31 30 bool _sending = false; 32 31 33 - void _copyToClipboard(String text, BuildContext context, bool isLink) { 34 - Clipboard.setData(ClipboardData(text: text)); 35 - 32 + void _toggleSelection(String convoId) { 36 33 setState(() { 37 - if (isLink) { 38 - _copiedLink = true; 39 - } else { 40 - _copiedEmbed = true; 41 - } 34 + _selectedConvoId = _selectedConvoId == convoId ? null : convoId; 42 35 }); 36 + } 37 + 38 + void _copyLink() { 39 + Clipboard.setData(ClipboardData(text: widget.shareUrl)); 40 + setState(() => _copiedLink = true); 43 41 44 42 Future.delayed(const Duration(seconds: 2), () { 43 + if (!mounted) return; 44 + setState(() => _copiedLink = false); 45 + }); 46 + } 47 + 48 + Future<void> _shareNatively() async { 49 + final logger = GetIt.instance<LogService>().getLogger('SharePanel'); 50 + try { 51 + await SharePlus.instance.share( 52 + ShareParams(uri: Uri.parse(widget.shareUrl)), 53 + ); 54 + } catch (e, st) { 55 + logger.e( 56 + 'Failed to open native share sheet', 57 + error: e, 58 + stackTrace: st, 59 + ); 60 + } 61 + } 62 + 63 + Future<void> _sendToSelectedConversation() async { 64 + final convoId = _selectedConvoId; 65 + if (convoId == null || _sending) return; 66 + 67 + final logger = GetIt.instance<LogService>().getLogger('SharePanel'); 68 + final navigator = Navigator.of(context); 69 + 70 + setState(() => _sending = true); 71 + try { 72 + await ref.read(conversationProvider(convoId).future); 73 + await ref 74 + .read(conversationProvider(convoId).notifier) 75 + .sendMessage(convoId, '', embed: widget.atUri); 76 + 77 + navigator.maybePop(); 78 + } catch (e, st) { 79 + logger.e( 80 + 'Failed to share video to conversation', 81 + error: e, 82 + stackTrace: st, 83 + ); 84 + } finally { 45 85 if (mounted) { 46 - setState(() { 47 - if (isLink) { 48 - _copiedLink = false; 49 - } else { 50 - _copiedEmbed = false; 51 - } 52 - }); 86 + setState(() => _sending = false); 53 87 } 54 - }); 88 + } 55 89 } 56 90 57 91 @override 58 92 Widget build(BuildContext context) { 59 93 final theme = Theme.of(context); 60 94 final textColor = theme.colorScheme.onSurface; 61 - final fieldBgColor = theme.colorScheme.surfaceContainerHighest; 62 95 final dividerColor = theme.colorScheme.outline.withValues(alpha: 0.2); 63 - 64 96 final convosAsync = ref.watch(conversationsProvider); 65 - 66 - final logger = GetIt.instance<LogService>().getLogger('SharePanel'); 97 + final hasSelection = _selectedConvoId != null; 67 98 68 99 return Container( 69 100 decoration: BoxDecoration( ··· 79 110 ), 80 111 ], 81 112 ), 82 - child: DraggableScrollableSheet( 83 - initialChildSize: 0.6, 84 - minChildSize: 0.35, 85 - maxChildSize: 0.85, 86 - expand: false, 87 - builder: (context, scrollController) { 88 - return Column( 113 + child: SafeArea( 114 + top: false, 115 + child: SingleChildScrollView( 116 + child: Column( 117 + mainAxisSize: MainAxisSize.min, 89 118 children: [ 90 119 Container( 91 120 width: 40, ··· 99 128 Padding( 100 129 padding: const EdgeInsets.symmetric(horizontal: 20), 101 130 child: Text( 102 - 'Share Video', 131 + 'Share', 103 132 style: TextStyle( 104 133 color: textColor, 105 134 fontSize: 18, ··· 108 137 ), 109 138 ), 110 139 Divider(color: dividerColor, height: 30), 111 - Expanded( 112 - child: ListView( 113 - controller: scrollController, 114 - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), 140 + Padding( 141 + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), 142 + child: Column( 143 + crossAxisAlignment: CrossAxisAlignment.start, 115 144 children: [ 116 - // Conversations selector 117 - Text( 118 - 'Send to', 119 - style: TextStyle( 120 - color: textColor, 121 - fontSize: 15, 122 - fontWeight: FontWeight.w600, 123 - ), 124 - ), 125 - const SizedBox(height: 8), 126 - Builder( 127 - builder: (_) { 128 - return convosAsync.when( 129 - data: (data) { 130 - final items = data.conversations; 131 - if (items.isEmpty) { 132 - return Text( 133 - 'No conversations yet', 134 - style: TextStyle( 135 - color: textColor.withAlpha(153), 136 - ), 145 + convosAsync.when( 146 + data: (data) { 147 + final items = data.conversations; 148 + if (items.isEmpty) { 149 + return Padding( 150 + padding: const EdgeInsets.symmetric(vertical: 8), 151 + child: Text( 152 + 'No conversations yet', 153 + style: TextStyle(color: textColor.withAlpha(153)), 154 + ), 155 + ); 156 + } 157 + 158 + return SizedBox( 159 + height: 112, 160 + child: ListView.separated( 161 + scrollDirection: Axis.horizontal, 162 + itemCount: items.length, 163 + separatorBuilder: (context, index) => 164 + const SizedBox(width: 12), 165 + itemBuilder: (_, index) { 166 + final (profile, convo) = items[index]; 167 + return _ConvoProfileChip( 168 + displayName: 169 + profile.displayName ?? profile.handle, 170 + avatarUrl: profile.avatar?.toString(), 171 + selected: _selectedConvoId == convo.id, 172 + onTap: () => _toggleSelection(convo.id), 137 173 ); 138 - } 139 - return Column( 140 - children: [ 141 - for (final (profile, convo) in items) 142 - _ConvoListTile( 143 - displayName: 144 - profile.displayName ?? profile.handle, 145 - handle: profile.handle, 146 - avatarUrl: profile.avatar?.toString(), 147 - selected: _selectedConvoId == convo.id, 148 - onAvatarTap: () { 149 - setState( 150 - () => _selectedConvoId = convo.id, 151 - ); 152 - }, 153 - onTileTap: () { 154 - setState( 155 - () => _selectedConvoId = convo.id, 156 - ); 157 - }, 158 - ), 159 - ], 160 - ); 161 - }, 162 - loading: () => const Padding( 163 - padding: EdgeInsets.symmetric(vertical: 24), 164 - child: Center(child: CircularProgressIndicator()), 165 - ), 166 - error: (e, st) => Text( 167 - 'Failed to load conversations', 168 - style: TextStyle(color: theme.colorScheme.error), 174 + }, 169 175 ), 170 176 ); 171 177 }, 172 - ), 173 - const SizedBox(height: 16), 174 - Divider(color: dividerColor), 175 - const SizedBox(height: 16), 176 - Text( 177 - 'Video link', 178 - style: TextStyle( 179 - color: textColor, 180 - fontSize: 15, 181 - fontWeight: FontWeight.w500, 182 - ), 183 - ), 184 - const SizedBox(height: 8), 185 - CopyField( 186 - text: widget.shareUrl, 187 - context: context, 188 - bgColor: fieldBgColor, 189 - textColor: textColor, 190 - isLink: true, 191 - isCopied: _copiedLink, 192 - onCopy: _copyToClipboard, 193 - ), 194 - if (widget.showEmbed) ...[ 195 - const SizedBox(height: 24), 196 - Text( 197 - 'Video embed', 198 - style: TextStyle( 199 - color: textColor, 200 - fontSize: 15, 201 - fontWeight: FontWeight.w500, 178 + loading: () => const _ConvoProfilesSkeleton(), 179 + error: (e, st) => Padding( 180 + padding: const EdgeInsets.symmetric(vertical: 8), 181 + child: Text( 182 + 'Failed to load conversations', 183 + style: TextStyle(color: theme.colorScheme.error), 202 184 ), 203 185 ), 204 - const SizedBox(height: 8), 205 - CopyField( 206 - text: widget.embedCode, 207 - context: context, 208 - bgColor: fieldBgColor, 209 - textColor: textColor, 210 - isLink: false, 211 - isCopied: _copiedEmbed, 212 - onCopy: _copyToClipboard, 213 - ), 214 - ], 186 + ), 215 187 ], 216 188 ), 217 189 ), 218 - SafeArea( 219 - top: false, 220 - child: Padding( 221 - padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), 222 - child: SizedBox( 223 - width: double.infinity, 224 - child: FilledButton.icon( 225 - onPressed: (_selectedConvoId == null || _sending) 226 - ? null 227 - : () async { 228 - final navigator = Navigator.of(context); 229 - 230 - setState(() => _sending = true); 231 - try { 232 - final convoId = _selectedConvoId!; 233 - // Ensure conversation is loaded before sending 234 - await ref.read( 235 - conversationProvider(convoId).future, 236 - ); 237 - // Send empty message with embed set to post URI 238 - await ref 239 - .read( 240 - conversationProvider(convoId).notifier, 241 - ) 242 - .sendMessage( 243 - convoId, 244 - '', 245 - embed: widget.atUri, 246 - ); 247 - 248 - navigator.maybePop(); 249 - } catch (e) { 250 - logger.d( 251 - 'Failed to share video to conversation', 252 - error: e, 253 - ); 254 - } finally { 255 - if (mounted) setState(() => _sending = false); 256 - } 257 - }, 258 - icon: _sending 259 - ? SizedBox( 260 - width: 16, 261 - height: 16, 262 - child: CircularProgressIndicator( 263 - strokeWidth: 2, 264 - valueColor: AlwaysStoppedAnimation( 265 - theme.colorScheme.onPrimary, 190 + Padding( 191 + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), 192 + child: SizedBox( 193 + height: _actionButtonHeight, 194 + child: AnimatedSwitcher( 195 + duration: _motionDuration, 196 + switchInCurve: _motionCurve, 197 + switchOutCurve: Curves.easeInCubic, 198 + transitionBuilder: (child, animation) { 199 + final fadeAnimation = CurvedAnimation( 200 + parent: animation, 201 + curve: Curves.easeOut, 202 + ); 203 + if (!hasSelection) { 204 + return FadeTransition( 205 + opacity: fadeAnimation, 206 + child: child, 207 + ); 208 + } 209 + final scaleAnimation = 210 + Tween<double>( 211 + begin: 0.7, 212 + end: 1, 213 + ).animate( 214 + CurvedAnimation( 215 + parent: animation, 216 + curve: Curves.easeOutBack, 217 + ), 218 + ); 219 + return FadeTransition( 220 + opacity: fadeAnimation, 221 + child: ScaleTransition( 222 + scale: scaleAnimation, 223 + child: child, 224 + ), 225 + ); 226 + }, 227 + child: hasSelection 228 + ? SizedBox( 229 + key: const ValueKey('selected-actions'), 230 + width: double.infinity, 231 + child: LongButton( 232 + label: _sending ? 'Sending...' : 'Send', 233 + onPressed: _sending 234 + ? null 235 + : _sendToSelectedConversation, 236 + ), 237 + ) 238 + : Row( 239 + key: const ValueKey('default-actions'), 240 + children: [ 241 + Expanded( 242 + child: LongButton( 243 + label: _copiedLink ? 'Copied' : 'Copy link', 244 + onPressed: _copyLink, 245 + variant: LongButtonVariant.regular, 266 246 ), 267 247 ), 268 - ) 269 - : const Icon(Icons.send_rounded), 270 - label: const Text('Share'), 271 - ), 248 + const SizedBox(width: 12), 249 + Expanded( 250 + child: LongButton( 251 + label: 'Share', 252 + onPressed: _shareNatively, 253 + variant: LongButtonVariant.regular, 254 + ), 255 + ), 256 + ], 257 + ), 272 258 ), 273 259 ), 274 260 ), 275 261 ], 276 - ); 277 - }, 278 - ), 279 - ); 280 - } 281 - } 282 - 283 - class CopyField extends StatelessWidget { 284 - const CopyField({ 285 - required this.text, 286 - required this.context, 287 - required this.bgColor, 288 - required this.textColor, 289 - required this.isLink, 290 - required this.isCopied, 291 - required this.onCopy, 292 - super.key, 293 - }); 294 - final String text; 295 - final BuildContext context; 296 - final Color bgColor; 297 - final Color textColor; 298 - final bool isLink; 299 - final bool isCopied; 300 - final Function(String, BuildContext, bool) onCopy; 301 - 302 - @override 303 - Widget build(BuildContext context) { 304 - final theme = Theme.of(context); 305 - final accentColor = theme.colorScheme.primary; 306 - 307 - return Container( 308 - decoration: BoxDecoration( 309 - color: bgColor, 310 - borderRadius: BorderRadius.circular(12), 311 - border: Border.all(color: Colors.transparent), 312 - ), 313 - child: Row( 314 - children: [ 315 - Expanded( 316 - child: Padding( 317 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 318 - child: Text( 319 - text, 320 - style: TextStyle( 321 - fontFamily: 'monospace', 322 - color: textColor.withAlpha(204), 323 - fontSize: 13, 324 - ), 325 - overflow: TextOverflow.ellipsis, 326 - ), 327 - ), 328 262 ), 329 - Material( 330 - color: Colors.transparent, 331 - child: InkWell( 332 - onTap: () => onCopy(text, context, isLink), 333 - borderRadius: BorderRadius.circular(30), 334 - child: Container( 335 - padding: const EdgeInsets.all(12), 336 - child: AnimatedSwitcher( 337 - duration: const Duration(milliseconds: 300), 338 - transitionBuilder: 339 - (Widget child, Animation<double> animation) { 340 - return ScaleTransition(scale: animation, child: child); 341 - }, 342 - child: isCopied 343 - ? const Icon( 344 - Icons.check_circle, 345 - key: ValueKey('copied'), 346 - color: Colors.green, 347 - size: 20, 348 - ) 349 - : Icon( 350 - Icons.content_copy_rounded, 351 - key: const ValueKey('copy'), 352 - color: accentColor, 353 - size: 20, 354 - ), 355 - ), 356 - ), 357 - ), 358 - ), 359 - ], 263 + ), 360 264 ), 361 265 ); 362 266 } 363 267 } 364 268 365 - class _ConvoListTile extends StatelessWidget { 366 - const _ConvoListTile({ 269 + class _ConvoProfileChip extends StatelessWidget { 270 + const _ConvoProfileChip({ 367 271 required this.displayName, 368 - required this.handle, 369 272 required this.avatarUrl, 370 273 required this.selected, 371 - required this.onAvatarTap, 372 - required this.onTileTap, 274 + required this.onTap, 373 275 }); 374 276 375 277 final String displayName; 376 - final String handle; 377 278 final String? avatarUrl; 378 279 final bool selected; 379 - final VoidCallback onAvatarTap; 380 - final VoidCallback onTileTap; 280 + final VoidCallback onTap; 381 281 382 282 @override 383 283 Widget build(BuildContext context) { 384 284 final theme = Theme.of(context); 385 285 final textColor = theme.colorScheme.onSurface; 286 + const avatarSize = 64.0; 287 + const motionDuration = Duration(milliseconds: 300); 288 + const motionCurve = Curves.easeOutCubic; 386 289 387 - return InkWell( 388 - onTap: onTileTap, 389 - child: Padding( 390 - padding: const EdgeInsets.symmetric(vertical: 8), 391 - child: Row( 392 - children: [ 393 - GestureDetector( 394 - onTap: onAvatarTap, 395 - child: Stack( 396 - alignment: Alignment.center, 397 - children: [ 398 - ClipOval( 399 - child: (avatarUrl != null && avatarUrl!.isNotEmpty) 400 - ? CachedNetworkImage( 401 - imageUrl: avatarUrl!, 402 - width: 44, 403 - height: 44, 404 - fit: BoxFit.cover, 405 - errorWidget: (context, url, error) => Container( 406 - width: 44, 407 - height: 44, 408 - color: theme.colorScheme.surfaceContainerHighest, 409 - child: Center( 410 - child: Text( 411 - displayName.isNotEmpty 412 - ? displayName.characters.first 413 - .toUpperCase() 414 - : '?', 415 - style: TextStyle( 416 - color: theme.colorScheme.onSurface, 417 - fontWeight: FontWeight.bold, 418 - ), 419 - ), 290 + return SizedBox( 291 + width: 82, 292 + child: InkWell( 293 + borderRadius: BorderRadius.circular(14), 294 + onTap: onTap, 295 + child: Padding( 296 + padding: const EdgeInsets.symmetric(vertical: 2), 297 + child: AnimatedScale( 298 + duration: motionDuration, 299 + curve: motionCurve, 300 + scale: selected ? 1 : 0.9, 301 + child: Column( 302 + mainAxisSize: MainAxisSize.min, 303 + children: [ 304 + Stack( 305 + clipBehavior: Clip.none, 306 + children: [ 307 + SizedBox( 308 + width: avatarSize, 309 + height: avatarSize, 310 + child: ClipOval( 311 + child: (avatarUrl != null && avatarUrl!.isNotEmpty) 312 + ? CachedNetworkImage( 313 + fadeInDuration: Duration.zero, 314 + imageUrl: avatarUrl!, 315 + fit: BoxFit.cover, 316 + errorWidget: (context, url, error) => 317 + _FallbackAvatar( 318 + displayName: displayName, 319 + theme: theme, 320 + ), 321 + ) 322 + : _FallbackAvatar( 323 + displayName: displayName, 324 + theme: theme, 420 325 ), 421 - ), 422 - ) 423 - : Container( 424 - width: 44, 425 - height: 44, 426 - color: theme.colorScheme.surfaceContainerHighest, 427 - child: Center( 428 - child: Text( 429 - displayName.isNotEmpty 430 - ? displayName.characters.first.toUpperCase() 431 - : '?', 432 - style: TextStyle( 433 - color: theme.colorScheme.onSurface, 434 - fontWeight: FontWeight.bold, 435 - ), 326 + ), 327 + ), 328 + Positioned( 329 + right: -2, 330 + bottom: -2, 331 + child: AnimatedScale( 332 + duration: motionDuration, 333 + curve: motionCurve, 334 + scale: selected ? 1 : 0, 335 + child: AnimatedOpacity( 336 + duration: motionDuration, 337 + curve: motionCurve, 338 + opacity: selected ? 1 : 0, 339 + child: Container( 340 + width: 24, 341 + height: 24, 342 + decoration: BoxDecoration( 343 + color: theme.colorScheme.primary, 344 + shape: BoxShape.circle, 345 + border: Border.all( 346 + color: theme.colorScheme.surface, 347 + width: 2, 436 348 ), 437 349 ), 350 + child: Icon( 351 + Icons.check, 352 + size: 15, 353 + color: theme.colorScheme.onPrimary, 354 + ), 438 355 ), 439 - ), 440 - if (selected) 441 - Container( 442 - width: 48, 443 - height: 48, 444 - decoration: BoxDecoration( 445 - shape: BoxShape.circle, 446 - border: Border.all( 447 - color: theme.colorScheme.primary, 448 - width: 2, 449 356 ), 450 357 ), 451 358 ), 452 - ], 453 - ), 454 - ), 455 - const SizedBox(width: 12), 456 - Expanded( 457 - child: Column( 458 - crossAxisAlignment: CrossAxisAlignment.start, 459 - children: [ 460 - Text( 461 - displayName, 462 - style: TextStyle( 463 - color: textColor, 464 - fontWeight: FontWeight.w600, 465 - ), 466 - overflow: TextOverflow.ellipsis, 359 + ], 360 + ), 361 + const SizedBox(height: 8), 362 + AnimatedDefaultTextStyle( 363 + duration: motionDuration, 364 + curve: motionCurve, 365 + style: TextStyle( 366 + color: textColor, 367 + fontSize: 13, 368 + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, 467 369 ), 468 - const SizedBox(height: 2), 469 - Text( 470 - '@$handle', 471 - style: TextStyle( 472 - color: textColor.withAlpha(153), 473 - fontSize: 12, 474 - ), 370 + child: Text( 371 + displayName, 372 + maxLines: 1, 475 373 overflow: TextOverflow.ellipsis, 374 + textAlign: TextAlign.center, 476 375 ), 477 - ], 478 - ), 376 + ), 377 + ], 479 378 ), 480 - if (selected) 481 - Icon( 482 - Icons.check_circle, 483 - color: theme.colorScheme.primary, 379 + ), 380 + ), 381 + ), 382 + ); 383 + } 384 + } 385 + 386 + class _ConvoProfilesSkeleton extends StatelessWidget { 387 + const _ConvoProfilesSkeleton(); 388 + 389 + @override 390 + Widget build(BuildContext context) { 391 + final theme = Theme.of(context); 392 + final skeletonColor = theme.colorScheme.surfaceContainerHighest; 393 + 394 + return SizedBox( 395 + height: 112, 396 + child: Skeletonizer( 397 + child: ListView.separated( 398 + scrollDirection: Axis.horizontal, 399 + itemCount: 5, 400 + separatorBuilder: (context, index) => const SizedBox(width: 12), 401 + itemBuilder: (_, index) { 402 + return SizedBox( 403 + width: 82, 404 + child: Transform.scale( 405 + scale: 0.9, 406 + alignment: Alignment.topCenter, 407 + child: Column( 408 + children: [ 409 + Skeleton.leaf( 410 + child: Container( 411 + width: 64, 412 + height: 64, 413 + decoration: BoxDecoration( 414 + shape: BoxShape.circle, 415 + color: skeletonColor, 416 + ), 417 + ), 418 + ), 419 + const SizedBox(height: 8), 420 + Skeleton.leaf( 421 + child: Container( 422 + width: 58, 423 + height: 13, 424 + decoration: BoxDecoration( 425 + borderRadius: BorderRadius.circular(6), 426 + color: skeletonColor, 427 + ), 428 + ), 429 + ), 430 + ], 431 + ), 484 432 ), 485 - ], 433 + ); 434 + }, 435 + ), 436 + ), 437 + ); 438 + } 439 + } 440 + 441 + class _FallbackAvatar extends StatelessWidget { 442 + const _FallbackAvatar({required this.displayName, required this.theme}); 443 + 444 + final String displayName; 445 + final ThemeData theme; 446 + 447 + @override 448 + Widget build(BuildContext context) { 449 + return ColoredBox( 450 + color: theme.colorScheme.surfaceContainerHighest, 451 + child: Center( 452 + child: Text( 453 + displayName.isNotEmpty 454 + ? displayName.characters.first.toUpperCase() 455 + : '?', 456 + style: TextStyle( 457 + color: theme.colorScheme.onSurface, 458 + fontWeight: FontWeight.bold, 459 + ), 486 460 ), 487 461 ), 488 462 );
-92
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 236 236 final originalAtUri = currentPost.uri.toString(); 237 237 var postUri = originalAtUri; 238 238 String shareUrl; 239 - var embedCode = ''; 240 - var showEmbed = true; 241 239 242 240 // Special case for Bluesky posts 243 241 if (postUri.contains('/app.bsky.feed.post/')) { ··· 257 255 258 256 // Format as Bluesky URL 259 257 shareUrl = 'https://bsky.app/profile/$did/post/$postId'; 260 - 261 - // Hide embed for Bluesky 262 - showEmbed = false; 263 258 } else { 264 259 // Fallback if parsing fails 265 260 shareUrl = 'https://bsky.app'; 266 - showEmbed = false; 267 261 } 268 262 } else { 269 263 // Standard Spark format ··· 276 270 postUri = postUri.replaceAll('so.sprk.feed.post/', ''); 277 271 278 272 shareUrl = 'https://watch.sprk.so/?uri=$postUri'; 279 - embedCode = 280 - '<iframe src="embed.html?uri=$postUri" width="100%" height="400" frameborder="0" allowfullscreen></iframe>'; 281 273 } 282 274 283 275 showModalBottomSheet( ··· 287 279 builder: (BuildContext context) { 288 280 return SharePanel( 289 281 shareUrl: shareUrl, 290 - embedCode: embedCode, 291 282 atUri: originalAtUri, 292 - showEmbed: showEmbed, 293 283 ); 294 284 }, 295 285 ); ··· 476 466 ); 477 467 } 478 468 } 479 - 480 - class CopyField extends StatelessWidget { 481 - const CopyField({ 482 - required this.text, 483 - required this.context, 484 - required this.bgColor, 485 - required this.textColor, 486 - required this.isLink, 487 - required this.isCopied, 488 - required this.onCopy, 489 - super.key, 490 - }); 491 - final String text; 492 - final BuildContext context; 493 - final Color bgColor; 494 - final Color textColor; 495 - final bool isLink; 496 - final bool isCopied; 497 - final Function(String, BuildContext, bool) onCopy; 498 - 499 - @override 500 - Widget build(BuildContext context) { 501 - final theme = Theme.of(context); 502 - final accentColor = theme.colorScheme.primary; 503 - 504 - return Container( 505 - decoration: BoxDecoration( 506 - color: bgColor, 507 - borderRadius: BorderRadius.circular(12), 508 - border: Border.all(color: Colors.transparent), 509 - ), 510 - child: Row( 511 - children: [ 512 - Expanded( 513 - child: Padding( 514 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 515 - child: Text( 516 - text, 517 - style: TextStyle( 518 - fontFamily: 'monospace', 519 - color: textColor.withAlpha(204), 520 - fontSize: 13, 521 - ), 522 - overflow: TextOverflow.ellipsis, 523 - ), 524 - ), 525 - ), 526 - Material( 527 - color: Colors.transparent, 528 - child: InkWell( 529 - onTap: () => onCopy(text, context, isLink), 530 - borderRadius: BorderRadius.circular(30), 531 - child: Container( 532 - padding: const EdgeInsets.all(12), 533 - child: AnimatedSwitcher( 534 - duration: const Duration(milliseconds: 300), 535 - transitionBuilder: 536 - (Widget child, Animation<double> animation) { 537 - return ScaleTransition(scale: animation, child: child); 538 - }, 539 - child: isCopied 540 - ? const Icon( 541 - Icons.check_circle, 542 - key: ValueKey('copied'), 543 - color: Colors.green, 544 - size: 20, 545 - ) 546 - : Icon( 547 - Icons.content_copy_rounded, 548 - key: const ValueKey('copy'), 549 - color: accentColor, 550 - size: 20, 551 - ), 552 - ), 553 - ), 554 - ), 555 - ), 556 - ], 557 - ), 558 - ); 559 - } 560 - }