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

known interactions

+1120 -80
+6
assets/icons/repost.svg
··· 1 + <svg width="19" height="24" viewBox="0 0 19 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M18.0317 10.3085C18.0317 9.89434 17.696 9.55855 17.2817 9.55855C16.8675 9.55854 16.5317 9.89433 16.5317 10.3085L17.2817 10.3085L18.0317 10.3085ZM12.0216 20.0183V19.2683L0.749979 19.2683V20.0183V20.7683L12.0216 20.7683V20.0183ZM17.2817 14.7582L18.0317 14.7582L18.0317 10.3085L17.2817 10.3085L16.5317 10.3085L16.5317 14.7582L17.2817 14.7582ZM12.0216 20.0183V20.7683C15.3409 20.7683 18.0317 18.0775 18.0317 14.7582L17.2817 14.7582L16.5317 14.7582C16.5317 17.2491 14.5125 19.2683 12.0216 19.2683V20.0183Z" fill="white"/> 3 + <path fill-rule="evenodd" clip-rule="evenodd" d="M0.241772 20.0177C0.241772 20.3792 0.426584 20.651 0.62834 20.8622C0.816252 21.059 1.11197 21.2918 1.41278 21.5288L2.11111 22.0789C2.62256 22.4818 3.05073 22.8191 3.40699 23.0259C3.76539 23.2338 4.20603 23.4027 4.66286 23.2005C5.13215 22.9927 5.28514 22.5435 5.34727 22.1393C5.4041 21.7695 5.40796 20.8622 5.40796 20.8622L5.40796 19.1746C5.40775 18.6001 5.40417 18.2678 5.34727 17.8975C5.28514 17.4933 5.13215 17.0441 4.66286 16.8363C4.20603 16.6341 3.76539 16.803 3.40699 17.0109C3.05073 17.2177 2.62256 17.555 2.11111 17.9579L1.41278 18.508C1.11197 18.745 0.816252 18.9779 0.62834 19.1746C0.426584 19.3858 0.241772 19.6563 0.241772 20.0177Z" fill="white"/> 4 + <path d="M0 12.983C-1.39863e-06 13.3972 0.335784 13.733 0.749997 13.733C1.16421 13.733 1.5 13.3972 1.5 12.983L0.75 12.983L0 12.983ZM6.01013 3.27319V4.02319H17.2818V3.27319V2.52319H6.01013V3.27319ZM0.750015 8.53329L1.50247e-05 8.53329L0 12.983L0.75 12.983L1.5 12.983L1.50002 8.5333L0.750015 8.53329ZM6.01013 3.27319V2.52319C2.69085 2.52319 2.62327e-05 5.214 1.50247e-05 8.53329L0.750015 8.53329L1.50002 8.5333C1.50002 6.04243 3.51927 4.02319 6.01013 4.02319V3.27319Z" fill="white"/> 5 + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.7902 3.27378C17.7902 2.91235 17.6054 2.64054 17.4036 2.42928C17.2157 2.23255 16.92 1.99966 16.6192 1.76272L15.9209 1.21262C15.4094 0.809666 14.9813 0.472377 14.625 0.26564C14.2666 0.0577042 13.826 -0.111237 13.3691 0.0910362C12.8998 0.298827 12.7468 0.748017 12.6847 1.15222C12.6279 1.52198 12.624 2.42928 12.624 2.42928V4.1169C12.6242 4.69138 12.6278 5.02374 12.6847 5.39398C12.7468 5.79818 12.8998 6.24737 13.3691 6.45518C13.826 6.65742 14.2666 6.48846 14.625 6.28057C14.9813 6.07385 15.4094 5.73653 15.9209 5.33357L16.6192 4.78348C16.92 4.54652 17.2157 4.31363 17.4036 4.1169C17.6054 3.90571 17.7902 3.63521 17.7902 3.27378Z" fill="white"/> 6 + </svg>
+6
assets/icons/repost_large.svg
··· 1 + <svg width="27" height="36" viewBox="0 0 27 36" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M26.218 22.8109L25.468 22.8109V22.8109L26.218 22.8109ZM26.968 15.9334C26.968 15.5191 26.6322 15.1834 26.218 15.1833C25.8038 15.1833 25.468 15.5191 25.468 15.9333L26.218 15.9333L26.968 15.9334ZM18.1248 30.941V30.191H0.782471V30.941V31.691H18.1248V30.941ZM26.218 22.8109L26.968 22.8109L26.968 15.9334L26.218 15.9333L25.468 15.9333L25.468 22.8109L26.218 22.8109ZM18.1248 30.941V31.691C23.012 31.691 26.968 27.712 26.968 22.8109L26.218 22.8109L25.468 22.8109C25.468 26.8901 22.1771 30.191 18.1248 30.191V30.941Z" fill="white"/> 3 + <path fill-rule="evenodd" clip-rule="evenodd" d="M0.000108778 30.94C0.000108778 31.4986 0.284457 31.9187 0.594878 32.2452C0.883995 32.5493 1.33898 32.9093 1.8018 33.2755L2.87624 34.1257C3.66315 34.7486 4.32193 35.2699 4.87007 35.5894C5.42149 35.9108 6.09946 36.1719 6.80233 35.8593C7.52438 35.5381 7.75976 34.8438 7.85536 34.2191C7.94279 33.6476 7.94873 32.1026 7.94873 32.1026L7.94873 29.7743C7.94841 28.8863 7.9429 28.2352 7.85536 27.6629C7.75976 27.0382 7.52438 26.3439 6.80233 26.0227C6.09946 25.7101 5.42149 25.9713 4.87007 26.2926C4.32193 26.6121 3.66315 27.1335 2.87624 27.7563L1.8018 28.6065C1.33898 28.9728 0.883995 29.3327 0.594878 29.6368C0.284457 29.9632 0.000108778 30.3813 0.000108778 30.94Z" fill="white"/> 4 + <path d="M0.782494 13.1892L1.53249 13.1892L0.782494 13.1892ZM0.0324707 20.0668C0.0324693 20.481 0.368255 20.8168 0.782468 20.8168C1.19668 20.8168 1.53247 20.481 1.53247 20.0668L0.782471 20.0668L0.0324707 20.0668ZM8.87564 5.05908V5.80908H26.218V5.05908V4.30908H8.87564V5.05908ZM0.782494 13.1892L0.0324938 13.1892L0.0324707 20.0668L0.782471 20.0668L1.53247 20.0668L1.53249 13.1892L0.782494 13.1892ZM8.87564 5.05908V4.30908C3.98849 4.30908 0.0325103 8.28809 0.0324938 13.1892L0.782494 13.1892L1.53249 13.1892C1.53251 9.11006 4.82337 5.80908 8.87564 5.80908V5.05908Z" fill="white"/> 5 + <path fill-rule="evenodd" clip-rule="evenodd" d="M26.9999 5.06004C26.9999 4.50141 26.7155 4.0813 26.4051 3.75476C26.116 3.45069 25.661 3.09073 25.1982 2.72452L24.1238 1.87425C23.3368 1.25144 22.6781 0.730119 22.1299 0.41058C21.5785 0.0891892 20.9005 -0.171931 20.1977 0.140708C19.4756 0.461875 19.2402 1.15616 19.1446 1.7809C19.0572 2.35242 19.0513 3.75476 19.0513 3.75476V6.3632C19.0516 7.25113 19.0571 7.76483 19.1446 8.33709C19.2402 8.96184 19.4756 9.6561 20.1977 9.97731C20.9005 10.2899 21.5785 10.0287 22.1299 9.70742C22.6781 9.38791 23.3368 8.86655 24.1238 8.24372L25.1982 7.39347C25.661 7.02723 26.116 6.66727 26.4051 6.3632C26.7155 6.03677 26.9999 5.61867 26.9999 5.06004Z" fill="white"/> 6 + </svg>
+189
lib/src/core/design_system/components/atoms/avatar_stack.dart
··· 1 + import 'dart:math'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:sparksocial/src/core/ui/widgets/user_avatar.dart'; 5 + 6 + /// Data class representing an avatar in the stack. 7 + class AvatarData { 8 + const AvatarData({ 9 + required this.imageUrl, 10 + required this.username, 11 + }); 12 + 13 + final String imageUrl; 14 + final String username; 15 + } 16 + 17 + /// A widget that displays overlapping avatars in a horizontal stack. 18 + /// 19 + /// Shows up to [maxAvatars] avatars (default 5). The first [largeAvatarCount] 20 + /// avatars are displayed at [largeSize], and remaining avatars are displayed 21 + /// at [smallSize] with tighter overlap. 22 + class AvatarStack extends StatelessWidget { 23 + const AvatarStack({ 24 + required this.avatars, 25 + super.key, 26 + this.maxAvatars = 5, 27 + this.largeAvatarCount = 2, 28 + this.largeSize = 36, 29 + this.smallSize = 15, 30 + this.largeOverlap = 12, 31 + this.smallOverlap = 0, 32 + }); 33 + 34 + /// List of avatars to display. 35 + final List<AvatarData> avatars; 36 + 37 + /// Maximum number of avatars to show. 38 + final int maxAvatars; 39 + 40 + /// Number of avatars to show at large size. 41 + final int largeAvatarCount; 42 + 43 + /// Size of large avatars in pixels. 44 + final double largeSize; 45 + 46 + /// Size of small (overflow) avatars in pixels. 47 + final double smallSize; 48 + 49 + /// Overlap offset for large avatars in pixels. 50 + final double largeOverlap; 51 + 52 + /// Angular spacing for small avatars on the arc. 53 + /// Higher values = closer together, lower values = farther apart. 54 + final double smallOverlap; 55 + 56 + @override 57 + Widget build(BuildContext context) { 58 + if (avatars.isEmpty) { 59 + return const SizedBox.shrink(); 60 + } 61 + 62 + final displayAvatars = avatars.take(maxAvatars).toList(); 63 + final children = <Widget>[]; 64 + 65 + // Separate large and small avatars 66 + final largeAvatars = displayAvatars.take(largeAvatarCount).toList(); 67 + final smallAvatars = displayAvatars.skip(largeAvatarCount).toList(); 68 + 69 + // Calculate large avatars section width 70 + double largeAvatarsWidth = 0; 71 + for (var i = 0; i < largeAvatars.length; i++) { 72 + if (i == 0) { 73 + largeAvatarsWidth = largeSize; 74 + } else { 75 + largeAvatarsWidth += largeSize - largeOverlap; 76 + } 77 + } 78 + 79 + // Add large avatars (overlapping horizontally) 80 + for (var i = 0; i < largeAvatars.length; i++) { 81 + final avatar = largeAvatars[i]; 82 + double leftOffset = 0; 83 + for (var j = 0; j < i; j++) { 84 + leftOffset += largeSize - largeOverlap; 85 + } 86 + 87 + children.add( 88 + Positioned( 89 + left: leftOffset, 90 + top: (largeSize - largeSize) / 2, 91 + child: UserAvatar( 92 + imageUrl: avatar.imageUrl, 93 + username: avatar.username, 94 + size: largeSize, 95 + ), 96 + ), 97 + ); 98 + } 99 + 100 + // Add small avatars in a circular arc pattern 101 + if (smallAvatars.isNotEmpty) { 102 + // Special case: only 1 small avatar - show as a third large avatar 103 + if (smallAvatars.length == 1) { 104 + final avatar = smallAvatars[0]; 105 + double leftOffset = 0; 106 + for (var j = 0; j < largeAvatars.length; j++) { 107 + leftOffset += largeSize - largeOverlap; 108 + } 109 + 110 + children.add( 111 + Positioned( 112 + left: leftOffset, 113 + top: 0, 114 + child: UserAvatar( 115 + imageUrl: avatar.imageUrl, 116 + username: avatar.username, 117 + size: largeSize, 118 + ), 119 + ), 120 + ); 121 + } else { 122 + // Center of the arc is positioned to the right of large avatars 123 + final arcCenterX = largeAvatarsWidth + smallSize * 0.3; 124 + final arcCenterY = largeSize / 2; 125 + final arcRadius = largeSize / 2 - smallSize / 4; 126 + 127 + // Convert smallOverlap to an angular spacing 128 + // Higher overlap = smaller angle between avatars (closer together) 129 + final overlapFraction = smallOverlap / smallSize; 130 + // Angular step based on overlap - more overlap means smaller angle 131 + final angleStep = (1 - overlapFraction) * (pi / 4); 132 + 133 + // Fixed 3 positions: top (-angleStep), center (0), bottom (+angleStep) 134 + // For 2 avatars: use top and bottom positions 135 + // For 3 avatars: use all three positions 136 + final List<double> angles; 137 + if (smallAvatars.length == 2) { 138 + // Top and bottom only 139 + angles = [-angleStep, angleStep]; 140 + } else { 141 + // All three positions: top, center, bottom 142 + angles = [-angleStep, 0, angleStep]; 143 + } 144 + 145 + for (var i = 0; i < smallAvatars.length; i++) { 146 + final avatar = smallAvatars[i]; 147 + final angle = angles[i]; 148 + 149 + // Calculate position on the arc 150 + final x = arcCenterX + arcRadius * cos(angle); 151 + final y = arcCenterY + arcRadius * sin(angle); 152 + 153 + children.add( 154 + Positioned( 155 + left: x - smallSize / 2, 156 + top: y - smallSize / 2, 157 + child: UserAvatar( 158 + imageUrl: avatar.imageUrl, 159 + username: avatar.username, 160 + size: smallSize, 161 + ), 162 + ), 163 + ); 164 + } 165 + } 166 + } 167 + 168 + // Calculate total dimensions 169 + double totalWidth; 170 + if (smallAvatars.isEmpty) { 171 + totalWidth = largeAvatarsWidth; 172 + } else if (smallAvatars.length == 1) { 173 + // Third avatar is large 174 + totalWidth = largeAvatarsWidth + largeSize - largeOverlap; 175 + } else { 176 + totalWidth = largeAvatarsWidth + smallSize + largeSize / 2; 177 + } 178 + final totalHeight = largeSize; 179 + 180 + return SizedBox( 181 + width: totalWidth, 182 + height: totalHeight, 183 + child: Stack( 184 + clipBehavior: Clip.none, 185 + children: children.reversed.toList(), 186 + ), 187 + ); 188 + } 189 + }
+14
lib/src/core/design_system/components/atoms/icons.dart
··· 327 327 colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, 328 328 package: 'assets', 329 329 ); 330 + static Widget repost({double size = 24, Color? color}) => SvgPicture.asset( 331 + '$_path/repost.svg', 332 + width: size, 333 + height: size, 334 + colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, 335 + package: 'assets', 336 + ); 337 + static Widget repostLarge({double size = 24, Color? color}) => SvgPicture.asset( 338 + '$_path/repost_large.svg', 339 + width: size, 340 + height: size, 341 + colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, 342 + package: 'assets', 343 + ); 330 344 static Widget search({double size = 24, Color? color}) => SvgPicture.asset( 331 345 '$_path/search.svg', 332 346 width: size,
+108
lib/src/core/design_system/components/molecules/known_interactions_bar.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:gradient_borders/box_borders/gradient_box_border.dart'; 5 + import 'package:sparksocial/src/core/design_system/components/atoms/avatar_stack.dart'; 6 + import 'package:sparksocial/src/core/design_system/components/atoms/icons.dart'; 7 + import 'package:sparksocial/src/core/design_system/tokens/gradients.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 9 + import 'package:sparksocial/src/core/ui/theme/theme.dart'; 10 + 11 + /// A widget that displays known interactions (reposts and likes) as 12 + /// overlapping avatar stacks with icons in frosted glass pill containers. 13 + /// 14 + /// Shows reposts on the left with a green repost icon, and likes on the right 15 + /// with a pink heart icon. Only renders if there are actual interactions. 16 + class KnownInteractionsBar extends StatelessWidget { 17 + const KnownInteractionsBar({ 18 + required this.interactions, 19 + super.key, 20 + }); 21 + 22 + /// List of known interactions to display. 23 + final List<KnownInteraction>? interactions; 24 + 25 + @override 26 + Widget build(BuildContext context) { 27 + if (interactions == null || interactions!.isEmpty) { 28 + return const SizedBox.shrink(); 29 + } 30 + 31 + // Filter interactions by type 32 + final reposts = interactions!.whereType<KnownRepost>().toList(); 33 + final likes = interactions!.whereType<KnownLike>().toList(); 34 + 35 + // If no reposts or likes, don't render anything 36 + if (reposts.isEmpty && likes.isEmpty) { 37 + return const SizedBox.shrink(); 38 + } 39 + 40 + return Padding( 41 + padding: const EdgeInsets.only(left: 8), 42 + child: Row( 43 + mainAxisSize: MainAxisSize.min, 44 + children: [ 45 + if (reposts.isNotEmpty) 46 + _InteractionPill( 47 + icon: AppIcons.repost(size: 20, color: AppColors.green), 48 + avatars: reposts 49 + .map( 50 + (r) => AvatarData( 51 + imageUrl: r.by.avatar?.toString() ?? '', 52 + username: r.by.displayName ?? r.by.handle, 53 + ), 54 + ) 55 + .toList(), 56 + ), 57 + if (reposts.isNotEmpty && likes.isNotEmpty) const SizedBox(width: 12), 58 + if (likes.isNotEmpty) 59 + _InteractionPill( 60 + icon: AppIcons.likeFilled(color: AppColors.pink), 61 + avatars: likes 62 + .map( 63 + (l) => AvatarData( 64 + imageUrl: l.by.avatar?.toString() ?? '', 65 + username: l.by.displayName ?? l.by.handle, 66 + ), 67 + ) 68 + .toList(), 69 + ), 70 + ], 71 + ), 72 + ); 73 + } 74 + } 75 + 76 + /// A single interaction pill with icon and avatar stack. 77 + class _InteractionPill extends StatelessWidget { 78 + const _InteractionPill({ 79 + required this.icon, 80 + required this.avatars, 81 + }); 82 + 83 + final Widget icon; 84 + final List<AvatarData> avatars; 85 + 86 + @override 87 + Widget build(BuildContext context) { 88 + return Container( 89 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), 90 + decoration: const BoxDecoration( 91 + color: Color(0x33FFFFFF), 92 + borderRadius: BorderRadius.all(Radius.circular(500)), 93 + border: GradientBoxBorder( 94 + gradient: AppGradients.glassStroke, 95 + width: 2, 96 + ), 97 + ), 98 + child: Row( 99 + mainAxisSize: MainAxisSize.min, 100 + children: [ 101 + icon, 102 + const SizedBox(width: 6), 103 + AvatarStack(avatars: avatars), 104 + ], 105 + ), 106 + ); 107 + } 108 + }
+63 -10
lib/src/core/design_system/components/organisms/side_action_bar.dart
··· 2 2 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:sparksocial/src/core/design_system/components/atoms/icons.dart'; 5 - import 'package:sparksocial/src/core/design_system/tokens/constants.dart'; 5 + import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 6 6 7 7 /// Curate popover item data 8 8 class CurateDestination { ··· 16 16 super.key, 17 17 this.onLike, 18 18 this.onComment, 19 + this.onRepost, 19 20 this.onCurate, 20 21 this.onShare, 21 22 this.onSoundTap, 22 23 this.onOptions, 23 24 this.likeCount, 24 25 this.commentCount, 26 + this.repostCount, 25 27 this.curateCount, 26 28 this.shareCount, 27 29 this.isLiked = false, 30 + this.isReposted = false, 28 31 this.isCurated = false, 29 32 this.soundCover, 30 33 this.curateDestinations = const <CurateDestination>[ ··· 36 39 37 40 final VoidCallback? onLike; 38 41 final VoidCallback? onComment; 42 + final VoidCallback? onRepost; 39 43 final VoidCallback? onCurate; // called after a feed selection (or when opening?) 40 44 final VoidCallback? onShare; 41 45 final VoidCallback? onSoundTap; ··· 43 47 44 48 final String? likeCount; 45 49 final String? commentCount; 50 + final String? repostCount; 46 51 final String? curateCount; 47 52 final String? shareCount; 48 53 49 54 final bool isLiked; 55 + final bool isReposted; 50 56 final bool isCurated; 51 57 final String? soundCover; 52 58 final List<CurateDestination> curateDestinations; ··· 152 158 label: widget.commentCount, 153 159 onTap: widget.onComment, 154 160 ), 161 + const SizedBox(height: 13), 162 + _ActionItem( 163 + isActive: widget.isReposted, 164 + icon: AppIcons.repostLarge(size: 32, color: widget.isReposted ? AppColors.green : null), 165 + label: widget.repostCount, 166 + onTap: widget.onRepost, 167 + ), 155 168 ]; 156 169 157 170 if (widget.onCurate != null) { ··· 171 184 const SizedBox(height: 13), 172 185 _ActionItem( 173 186 icon: AppIcons.share(size: 32), 174 - label: widget.shareCount, 175 187 onTap: widget.onShare, 176 188 ), 177 189 ]); ··· 203 215 } 204 216 } 205 217 206 - class _ActionItem extends StatelessWidget { 218 + class _ActionItem extends StatefulWidget { 207 219 const _ActionItem({ 208 220 required this.icon, 209 221 this.label, ··· 218 230 final bool isActive; 219 231 220 232 @override 233 + State<_ActionItem> createState() => _ActionItemState(); 234 + } 235 + 236 + class _ActionItemState extends State<_ActionItem> with SingleTickerProviderStateMixin { 237 + late AnimationController _bounceController; 238 + late Animation<double> _bounceAnimation; 239 + 240 + @override 241 + void initState() { 242 + super.initState(); 243 + _bounceController = AnimationController( 244 + duration: const Duration(milliseconds: 300), 245 + vsync: this, 246 + ); 247 + _bounceAnimation = TweenSequence<double>([ 248 + TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40), 249 + TweenSequenceItem(tween: Tween(begin: 1.3, end: 0.9), weight: 30), 250 + TweenSequenceItem(tween: Tween(begin: 0.9, end: 1.05), weight: 30), 251 + ]).animate(CurvedAnimation(parent: _bounceController, curve: Curves.easeOut)); 252 + } 253 + 254 + @override 255 + void didUpdateWidget(_ActionItem oldWidget) { 256 + super.didUpdateWidget(oldWidget); 257 + if (oldWidget.isActive != widget.isActive) { 258 + _bounceController.forward(from: 0); 259 + } 260 + } 261 + 262 + @override 263 + void dispose() { 264 + _bounceController.dispose(); 265 + super.dispose(); 266 + } 267 + 268 + @override 221 269 Widget build(BuildContext context) { 222 270 return GestureDetector( 223 271 behavior: HitTestBehavior.opaque, 224 - onTap: onTap, 272 + onTap: widget.onTap, 225 273 child: Column( 226 274 children: [ 227 - AnimatedScale( 228 - scale: isActive ? 1.05 : 1.0, 229 - duration: AppConstants.animationFast, 230 - child: SizedBox(width: 40, height: 40, child: Center(child: icon)), 275 + AnimatedBuilder( 276 + animation: _bounceAnimation, 277 + builder: (context, child) { 278 + return Transform.scale( 279 + scale: _bounceController.isAnimating ? _bounceAnimation.value : (widget.isActive ? 1.05 : 1.0), 280 + child: child, 281 + ); 282 + }, 283 + child: SizedBox(width: 40, height: 40, child: Center(child: widget.icon)), 231 284 ), 232 - if (label != null && label!.isNotEmpty) 285 + if (widget.label != null && widget.label!.isNotEmpty) 233 286 Text( 234 - label!, 287 + widget.label!, 235 288 style: const TextStyle( 236 289 fontSize: 12, 237 290 height: 1.1,
-1
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 366 366 likeCount: likeCount, 367 367 replyCount: replyCount, 368 368 repostCount: repostCount, 369 - quoteCount: quoteCount, 370 369 ); 371 370 } 372 371 }
+71 -16
lib/src/core/network/atproto/data/models/feed_models.dart
··· 154 154 reply: (r) => r.reply.media, 155 155 ); 156 156 157 - Viewer? get viewer => map( 158 - post: (p) => p.post.viewer, 159 - reply: (r) => r.reply.viewer, 160 - ); 157 + ViewerState? get viewerState => mapOrNull(post: (p) => p.post.viewer); 158 + ReplyViewerState? get replyViewerState => mapOrNull(reply: (r) => r.reply.viewer); 161 159 162 160 String get displayText => map( 163 161 post: (p) => p.post.displayText, ··· 230 228 @freezed 231 229 abstract class BlockedAuthor with _$BlockedAuthor { 232 230 @JsonSerializable(explicitToJson: true) 233 - const factory BlockedAuthor({required String did, Viewer? viewer}) = _BlockedAuthor; 231 + const factory BlockedAuthor({required String did, ViewerState? viewer}) = _BlockedAuthor; 234 232 const BlockedAuthor._(); 235 233 236 234 factory BlockedAuthor.fromJson(Map<String, dynamic> json) => _$BlockedAuthorFromJson(json); ··· 245 243 factory PostThread.fromJson(Map<String, dynamic> json) => _$PostThreadFromJson(json); 246 244 } 247 245 246 + /// Metadata about the requesting account's relationship with the subject content. 247 + /// Only has meaningful content for authed requests. 248 248 @freezed 249 - abstract class Viewer with _$Viewer { 249 + abstract class ViewerState with _$ViewerState { 250 250 @JsonSerializable(explicitToJson: true) 251 - const factory Viewer({ 251 + const factory ViewerState({ 252 252 @AtUriConverter() AtUri? repost, 253 253 @AtUriConverter() AtUri? like, 254 254 bool? threadMuted, 255 255 bool? replyDisabled, 256 256 bool? embeddingDisabled, 257 - bool? pinned, 258 - }) = _Viewer; 259 - const Viewer._(); 257 + List<KnownInteraction>? knownInteractions, 258 + }) = _ViewerState; 259 + const ViewerState._(); 260 + 261 + factory ViewerState.fromJson(Map<String, dynamic> json) => _$ViewerStateFromJson(json); 262 + } 263 + 264 + /// Metadata about the requesting account's relationship with reply content. 265 + /// Only has meaningful content for authed requests. 266 + @freezed 267 + abstract class ReplyViewerState with _$ReplyViewerState { 268 + @JsonSerializable(explicitToJson: true) 269 + const factory ReplyViewerState({ 270 + @AtUriConverter() AtUri? like, 271 + bool? threadMuted, 272 + bool? replyDisabled, 273 + bool? embeddingDisabled, 274 + }) = _ReplyViewerState; 275 + const ReplyViewerState._(); 276 + 277 + factory ReplyViewerState.fromJson(Map<String, dynamic> json) => _$ReplyViewerStateFromJson(json); 278 + } 279 + 280 + @Freezed(unionKey: r'$type') 281 + sealed class KnownInteraction with _$KnownInteraction { 282 + const KnownInteraction._(); 283 + 284 + @FreezedUnionValue('so.sprk.feed.defs#knownRepost') 285 + @JsonSerializable(explicitToJson: true) 286 + const factory KnownInteraction.repost({ 287 + required ProfileViewBasic by, 288 + required DateTime indexedAt, 289 + @AtUriConverter() AtUri? uri, 290 + String? cid, 291 + }) = KnownRepost; 260 292 261 - factory Viewer.fromJson(Map<String, dynamic> json) => _$ViewerFromJson(json); 293 + @FreezedUnionValue('so.sprk.feed.defs#knownLike') 294 + @JsonSerializable(explicitToJson: true) 295 + const factory KnownInteraction.like({ 296 + required ProfileViewBasic by, 297 + required DateTime indexedAt, 298 + @AtUriConverter() AtUri? uri, 299 + String? cid, 300 + }) = KnownLike; 301 + 302 + @FreezedUnionValue('so.sprk.feed.defs#knownReply') 303 + @JsonSerializable(explicitToJson: true) 304 + const factory KnownInteraction.reply({ 305 + required ProfileViewBasic by, 306 + required DateTime indexedAt, 307 + @AtUriConverter() AtUri? uri, 308 + String? cid, 309 + String? text, 310 + }) = KnownReply; 311 + 312 + factory KnownInteraction.fromJson(Map<String, dynamic> json) => _$KnownInteractionFromJson(json); 262 313 } 263 314 264 315 @freezed ··· 274 325 int? likeCount, 275 326 int? replyCount, 276 327 int? repostCount, 277 - int? quoteCount, 278 328 List<Label>? labels, 279 - Viewer? viewer, 329 + ViewerState? viewer, 280 330 MediaView? media, 281 331 AudioView? sound, 282 332 }) = _PostView; ··· 690 740 ThreadReplyView(:final reply) => reply.cid, 691 741 }; 692 742 693 - Viewer? get viewer => switch (this) { 743 + ViewerState? get viewer => switch (this) { 694 744 ThreadPostView(:final post) => post.viewer, 695 - ThreadReplyView(:final reply) => reply.viewer, 745 + ThreadReplyView(:final reply) => ViewerState( 746 + like: reply.viewer?.like, 747 + threadMuted: reply.viewer?.threadMuted, 748 + replyDisabled: reply.viewer?.replyDisabled, 749 + embeddingDisabled: reply.viewer?.embeddingDisabled, 750 + ), 696 751 }; 697 752 698 753 int? get likeCount => switch (this) { ··· 1186 1241 int? replyCount, 1187 1242 int? likeCount, 1188 1243 List<Label>? labels, 1189 - Viewer? viewer, 1244 + ReplyViewerState? viewer, 1190 1245 }) = _ReplyView; 1191 1246 const ReplyView._(); 1192 1247
+11
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 86 86 /// [likeUri] The URI of the like to delete 87 87 Future<void> unlikePost(AtUri likeUri); 88 88 89 + /// Repost a post 90 + /// 91 + /// [postCid] The CID of the post to repost 92 + /// [postUri] The URI of the post to repost 93 + Future<RepoStrongRef> repostPost(String postCid, AtUri postUri); 94 + 95 + /// Unrepost a post (delete the repost) 96 + /// 97 + /// [repostUri] The URI of the repost to delete 98 + Future<void> unrepostPost(AtUri repostUri); 99 + 89 100 /// Delete a post by its URI 90 101 /// 91 102 /// [postUri] The URI of the post to delete
+29
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 734 734 } 735 735 736 736 @override 737 + Future<RepoStrongRef> repostPost(String postCid, AtUri postUri) async { 738 + _logger.d('Reposting post with CID: $postCid, URI: $postUri'); 739 + 740 + // Determine if this is a Bluesky post or Spark post 741 + final isBskyPost = postUri.collection.toString().startsWith('app.bsky.feed.post'); 742 + final repostType = isBskyPost ? 'app.bsky.feed.repost' : 'so.sprk.feed.repost'; 743 + 744 + _logger.d('Post type: ${isBskyPost ? 'Bluesky' : 'Spark'}, using collection: $repostType'); 745 + 746 + final repostRecord = { 747 + r'$type': repostType, 748 + 'subject': {'cid': postCid, 'uri': postUri.toString()}, 749 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 750 + }; 751 + 752 + final result = await _client.repo.createRecord(collection: repostType, record: repostRecord); 753 + _logger.i('Post reposted successfully: ${result.uri}'); 754 + 755 + return result; 756 + } 757 + 758 + @override 759 + Future<void> unrepostPost(AtUri repostUri) async { 760 + _logger.d('Unreposting post with repost URI: $repostUri'); 761 + await _client.repo.deleteRecord(uri: repostUri, skipBskyCrosspostCleanup: true); 762 + _logger.i('Post unreposted successfully'); 763 + } 764 + 765 + @override 737 766 Future<RepoStrongRef> postComment( 738 767 String text, 739 768 String parentCid,
+6 -5
lib/src/features/comments/providers/comment_provider.dart
··· 11 11 12 12 part 'comment_provider.g.dart'; 13 13 14 - @riverpod 14 + @Riverpod(keepAlive: true) 15 15 class CommentNotifier extends _$CommentNotifier { 16 16 @override 17 17 CommentState build(Thread thread) { ··· 31 31 Future<void> toggleLike() async { 32 32 final wasLiked = state.isLiked; 33 33 final currentLikeCount = state.thread.post.likeCount ?? 0; 34 + final postUri = state.thread.post.uri.toString(); 34 35 35 36 try { 36 37 if (wasLiked) { ··· 60 61 await _feedRepository.unlikePost(likeUri); 61 62 62 63 // Trigger UI updates 63 - ref.read(postUpdateProvider(state.thread.post.uri.toString()).notifier).state++; 64 + ref.read(postUpdateProvider(postUri).notifier).state++; 64 65 } else { 65 66 // Optimistically update UI for like 66 67 final response = await _feedRepository.likePost(state.thread.post.cid, state.thread.post.uri); ··· 68 69 final updatedPost = switch (state.thread.post) { 69 70 ThreadPostView(:final post) => ThreadPostView( 70 71 post: post.copyWith( 71 - viewer: post.viewer?.copyWith(like: response.uri) ?? Viewer(like: response.uri), 72 + viewer: post.viewer?.copyWith(like: response.uri) ?? ViewerState(like: response.uri), 72 73 likeCount: currentLikeCount + 1, 73 74 ), 74 75 ), 75 76 ThreadReplyView(:final reply) => ThreadReplyView( 76 77 reply: reply.copyWith( 77 - viewer: reply.viewer?.copyWith(like: response.uri) ?? Viewer(like: response.uri), 78 + viewer: reply.viewer?.copyWith(like: response.uri) ?? ReplyViewerState(like: response.uri), 78 79 likeCount: currentLikeCount + 1, 79 80 ), 80 81 ), ··· 84 85 ); 85 86 86 87 // Trigger UI updates 87 - ref.read(postUpdateProvider(state.thread.post.uri.toString()).notifier).state++; 88 + ref.read(postUpdateProvider(postUri).notifier).state++; 88 89 } 89 90 } catch (e) { 90 91 // Revert optimistic update on error
+4 -6
lib/src/features/comments/ui/widgets/comment_item.dart
··· 1 1 import 'package:atproto/com_atproto_moderation_createreport.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 3 import 'package:auto_route/auto_route.dart'; 4 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 + import 'package:sparksocial/src/core/design_system/components/atoms/icons.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 7 import 'package:get_it/get_it.dart'; ··· 280 280 onPressed: notifier.toggleLike, 281 281 child: Row( 282 282 children: [ 283 - Icon( 284 - (!commentState.isLiked) ? FluentIcons.heart_24_regular : FluentIcons.heart_24_filled, 285 - size: 16, 286 - color: commentState.isLiked ? AppColors.red : secondaryTextColor, 287 - ), 283 + commentState.isLiked 284 + ? AppIcons.likeFilled(size: 16, color: AppColors.pink) 285 + : AppIcons.like(size: 16, color: secondaryTextColor), 288 286 const SizedBox(width: 4), 289 287 Text(commentState.likeCount.toString(), style: TextStyle(fontSize: 12, color: secondaryTextColor)), 290 288 ],
+25
lib/src/features/feed/providers/repost_post.dart
··· 1 + import 'package:atproto/com_atproto_repo_strongref.dart'; 2 + import 'package:atproto_core/atproto_core.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 + 7 + part 'repost_post.g.dart'; 8 + 9 + @riverpod 10 + Future<RepoStrongRef> repostPost(Ref ref, String postCid, AtUri postUri) async { 11 + try { 12 + return await GetIt.I<SprkRepository>().feed.repostPost(postCid, postUri); 13 + } catch (e) { 14 + throw Exception('Failed to repost post: $e'); 15 + } 16 + } 17 + 18 + @riverpod 19 + Future<void> unrepostPost(Ref ref, AtUri repostUri) async { 20 + try { 21 + await GetIt.I<SprkRepository>().feed.unrepostPost(repostUri); 22 + } catch (e) { 23 + throw Exception('Failed to unrepost post: $e'); 24 + } 25 + }
+84 -7
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 9 9 import 'package:sparksocial/src/core/ui/widgets/report_dialog.dart'; 10 10 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 11 11 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 12 + import 'package:sparksocial/src/features/feed/providers/repost_post.dart'; 12 13 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/share_panel.dart'; 13 14 14 15 class SideActionBar extends ConsumerStatefulWidget { ··· 40 41 41 42 class SideActionBarState extends ConsumerState<SideActionBar> { 42 43 bool _isLiked = false; 44 + bool _isReposted = false; 45 + int _likeCount = 0; 46 + int _repostCount = 0; 43 47 PostView? _currentPost; // Track the current post state locally 44 48 45 49 @override 46 50 void initState() { 47 51 super.initState(); 48 52 _isLiked = widget.isLiked; 53 + _isReposted = widget.post.viewer?.repost != null; 54 + _likeCount = int.tryParse(widget.likeCount) ?? widget.post.likeCount ?? 0; 55 + _repostCount = widget.post.repostCount ?? 0; 49 56 _currentPost = widget.post; // Initialize with the original post 50 57 } 51 58 ··· 60 67 if (oldWidget.post != widget.post) { 61 68 setState(() { 62 69 _currentPost = widget.post; 70 + _isReposted = widget.post.viewer?.repost != null; 71 + _likeCount = int.tryParse(widget.likeCount) ?? widget.post.likeCount ?? 0; 72 + _repostCount = widget.post.repostCount ?? 0; 63 73 }); 64 74 } 65 75 } ··· 69 79 if (mounted) { 70 80 setState(() { 71 81 _isLiked = updatedPost.viewer?.like != null; 82 + _likeCount = updatedPost.likeCount ?? _likeCount; 72 83 _currentPost = updatedPost; 73 84 }); 74 85 } 75 86 } 76 87 77 88 Future<void> _handleLike() async { 89 + final wasLiked = _isLiked; 78 90 setState(() { 79 91 _isLiked = !_isLiked; 92 + _likeCount += _isLiked ? 1 : -1; 80 93 }); 81 94 82 95 try { ··· 86 99 final newLike = await ref.read(likePostProvider(currentPost.cid, currentPost.uri).future); 87 100 88 101 final updatedPost = currentPost.copyWith( 102 + likeCount: _likeCount, 89 103 viewer: 90 - currentPost.viewer?.copyWith(like: newLike.uri) ?? Viewer(like: newLike.uri, repost: currentPost.viewer?.repost), 104 + currentPost.viewer?.copyWith(like: newLike.uri) ?? 105 + ViewerState(like: newLike.uri, repost: currentPost.viewer?.repost), 91 106 ); 92 107 93 108 if (widget.feed != null) { ··· 102 117 await ref.read(unlikePostProvider(AtUri.parse(currentPost.viewer!.like!.toString())).future); 103 118 104 119 final updatedPost = currentPost.copyWith( 105 - viewer: currentPost.viewer?.copyWith(like: null) ?? Viewer(repost: currentPost.viewer?.repost), 120 + likeCount: _likeCount, 121 + viewer: currentPost.viewer?.copyWith(like: null) ?? ViewerState(repost: currentPost.viewer?.repost), 122 + ); 123 + 124 + if (widget.feed != null) { 125 + ref.read(feedProvider(widget.feed!).notifier).replacePost(updatedPost); 126 + } 127 + 128 + _currentPost = updatedPost; 129 + } 130 + } 131 + } catch (e) { 132 + // Revert the UI state if the operation failed 133 + setState(() { 134 + _isLiked = wasLiked; 135 + _likeCount += wasLiked ? 1 : -1; 136 + }); 137 + 138 + // Show error to user 139 + if (mounted) { 140 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to ${wasLiked ? 'unlike' : 'like'} post: $e'))); 141 + } 142 + } 143 + } 144 + 145 + Future<void> _handleRepost() async { 146 + final wasReposted = _isReposted; 147 + setState(() { 148 + _isReposted = !_isReposted; 149 + _repostCount += _isReposted ? 1 : -1; 150 + }); 151 + 152 + try { 153 + if (_isReposted) { 154 + // Repost the post 155 + final currentPost = _currentPost ?? widget.post; 156 + final newRepost = await ref.read(repostPostProvider(currentPost.cid, currentPost.uri).future); 157 + 158 + final updatedPost = currentPost.copyWith( 159 + repostCount: _repostCount, 160 + viewer: 161 + currentPost.viewer?.copyWith(repost: newRepost.uri) ?? 162 + ViewerState(repost: newRepost.uri, like: currentPost.viewer?.like), 163 + ); 164 + 165 + if (widget.feed != null) { 166 + ref.read(feedProvider(widget.feed!).notifier).replacePost(updatedPost); 167 + } 168 + 169 + _currentPost = updatedPost; 170 + } else { 171 + // Unrepost the post 172 + final currentPost = _currentPost ?? widget.post; 173 + if (currentPost.viewer?.repost != null) { 174 + await ref.read(unrepostPostProvider(currentPost.viewer!.repost!).future); 175 + 176 + final updatedPost = currentPost.copyWith( 177 + repostCount: _repostCount, 178 + viewer: currentPost.viewer?.copyWith(repost: null) ?? ViewerState(like: currentPost.viewer?.like), 106 179 ); 107 180 108 181 if (widget.feed != null) { ··· 115 188 } catch (e) { 116 189 // Revert the UI state if the operation failed 117 190 setState(() { 118 - _isLiked = !_isLiked; 191 + _isReposted = wasReposted; 192 + _repostCount += wasReposted ? 1 : -1; 119 193 }); 120 194 121 195 // Show error to user 122 196 if (mounted) { 123 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to ${_isLiked ? 'like' : 'unlike'} post: $e'))); 197 + ScaffoldMessenger.of( 198 + context, 199 + ).showSnackBar(SnackBar(content: Text('Failed to ${wasReposted ? 'unrepost' : 'repost'} post: $e'))); 124 200 } 125 201 } 126 202 } ··· 226 302 // Curation disabled: do not build curate destinations from feeds 227 303 228 304 final currentPost = _currentPost ?? widget.post; 229 - final likeCount = int.tryParse(widget.likeCount) ?? 0; 230 305 231 306 final commentCount = currentPost.replyCount ?? int.tryParse(widget.commentCount) ?? 0; 232 - // final repostCount = currentPost.repostCount ?? int.tryParse(widget.shareCount) ?? 0; // Curation disabled 233 307 // final isCurated = currentPost.viewer?.repost != null; // Curation disabled 234 308 235 309 return SparkSideActionBar( 236 310 onLike: _handleLike, 237 311 onComment: _handleCommentPressed, 312 + onRepost: _handleRepost, 238 313 // onCurate: _handleCurate, // Curation disabled 239 314 onShare: _handleShare, 240 315 onSoundTap: currentPost.sound != null ? _handleSoundTap : null, ··· 242 317 context: context, 243 318 onReport: _handleReport, 244 319 ), 245 - likeCount: likeCount.toString(), 320 + likeCount: _likeCount.toString(), 246 321 commentCount: commentCount.toString(), 322 + repostCount: _repostCount.toString(), 247 323 // curateCount: repostCount.toString(), // Curation disabled 248 324 shareCount: widget.shareCount, 249 325 isLiked: _isLiked, 326 + isReposted: _isReposted, 250 327 soundCover: currentPost.sound?.coverArt.toString(), 251 328 // isCurated: isCurated, // Curation disabled 252 329 // curateDestinations: curateDestinations, // Curation disabled
+12 -11
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 67 67 } 68 68 69 69 Future<void> _handleDoubleTapLike(PostView postData) async { 70 - final isCurrentlyLiked = postData.viewer?.like != null; 70 + final isCurrentlyLiked = _overrideIsLiked ?? (postData.viewer?.like != null); 71 71 72 72 if (isCurrentlyLiked) { 73 73 return; ··· 82 82 // Like the post using the same logic as SideActionBar 83 83 final newLike = await ref.read(likePostProvider(postData.cid, postData.uri).future); 84 84 85 - // Update the post's viewer field with the new like reference 85 + // Update the post's viewer field with the new like reference and increment like count 86 86 final updatedPost = postData.copyWith( 87 - viewer: postData.viewer?.copyWith(like: newLike.uri) ?? Viewer(like: newLike.uri, repost: postData.viewer?.repost), 87 + likeCount: (postData.likeCount ?? 0) + 1, 88 + viewer: postData.viewer?.copyWith(like: newLike.uri) ?? ViewerState(like: newLike.uri, repost: postData.viewer?.repost), 88 89 ); 89 90 90 91 ref.read(feedProvider(widget.feed).notifier).replacePost(updatedPost); ··· 180 181 } 181 182 }); 182 183 183 - // Get labels for the overlay 184 + // Get labels for the overlay and use the latest post from feed state 184 185 var labels = <Label>[]; 185 - final feedState = ref.read(feedProvider(widget.feed)); 186 + // Use the post from feed state as it has the latest updates (e.g., after like/repost) 187 + final currentPost = (widget.index < feedState.loadedPosts.length) ? feedState.loadedPosts[widget.index] : postData; 186 188 if (widget.index < feedState.loadedPosts.length) { 187 - final post = feedState.loadedPosts[widget.index]; 188 - final extraInfo = feedState.extraInfo[post.uri]; 189 + final extraInfo = feedState.extraInfo[currentPost.uri]; 189 190 if (extraInfo != null) { 190 191 labels = extraInfo.postLabels; 191 192 } ··· 250 251 // Overlay controls - no double-tap detection, so buttons respond immediately 251 252 Positioned.fill( 252 253 child: PostOverlay( 253 - post: postData, 254 + post: currentPost, 254 255 feed: widget.feed, 255 - isLiked: _overrideIsLiked ?? (postData.viewer?.like != null), 256 + isLiked: _overrideIsLiked ?? (currentPost.viewer?.like != null), 256 257 labels: labels, 257 258 onProfilePressed: () { 258 259 _videoPlayerKey.currentState?.pauseVideo(); ··· 261 262 _videoPlayerKey.currentState?.pauseVideo(); 262 263 context.router.push( 263 264 ProfileRoute( 264 - did: postData.author.did, 265 - initialProfile: postData.author, 265 + did: currentPost.author.did, 266 + initialProfile: currentPost.author, 266 267 ), 267 268 ); 268 269 },
+32 -16
lib/src/features/feed/ui/widgets/post/post_overlay.dart
··· 1 1 import 'package:atproto/com_atproto_label_defs.dart'; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:sparksocial/src/core/design_system/components/molecules/known_interactions_bar.dart'; 3 4 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 4 5 import 'package:sparksocial/src/core/utils/label_utils.dart'; 5 6 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; ··· 65 66 children: [ 66 67 // Info Bar (Left side) 67 68 Expanded( 68 - child: FutureBuilder<List<String>>( 69 - future: LabelUtils.getInformLabels(labels), 70 - builder: (context, snapshot) { 71 - final informLabels = snapshot.data ?? []; 72 - return InfoBar( 73 - username: post.author.handle, 74 - displayName: post.author.displayName ?? post.author.handle, 75 - avatarUrl: post.author.avatar?.toString(), 76 - description: post.displayText, 77 - hashtags: post.record.hashtags, 78 - informLabels: informLabels, 79 - isSprk: post.uri.toString().contains('so.sprk'), 80 - audio: post.sound, 81 - onUsernameTap: onUsernameTap, 82 - ); 83 - }, 69 + child: Column( 70 + crossAxisAlignment: CrossAxisAlignment.start, 71 + mainAxisSize: MainAxisSize.min, 72 + children: [ 73 + // Known Interactions (reposts/likes from followed users) 74 + if (post.viewer?.knownInteractions != null && post.viewer!.knownInteractions!.isNotEmpty) 75 + Padding( 76 + padding: const EdgeInsets.only(bottom: 12), 77 + child: KnownInteractionsBar( 78 + interactions: post.viewer?.knownInteractions, 79 + ), 80 + ), 81 + // Author info and caption 82 + FutureBuilder<List<String>>( 83 + future: LabelUtils.getInformLabels(labels), 84 + builder: (context, snapshot) { 85 + final informLabels = snapshot.data ?? []; 86 + return InfoBar( 87 + username: post.author.handle, 88 + displayName: post.author.displayName ?? post.author.handle, 89 + avatarUrl: post.author.avatar?.toString(), 90 + description: post.displayText, 91 + hashtags: post.record.hashtags, 92 + informLabels: informLabels, 93 + isSprk: post.uri.toString().contains('so.sprk'), 94 + audio: post.sound, 95 + onUsernameTap: onUsernameTap, 96 + ); 97 + }, 98 + ), 99 + ], 84 100 ), 85 101 ), 86 102
-1
lib/src/features/profile/ui/pages/profile_page.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:auto_route/auto_route.dart'; 3 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 5 import 'package:get_it/get_it.dart';
+14 -3
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 10 10 import 'package:sparksocial/src/core/ui/widgets/content_warning_overlay.dart'; 11 11 import 'package:sparksocial/src/core/ui/widgets/heart_animation.dart'; 12 12 import 'package:sparksocial/src/core/utils/label_utils.dart'; 13 + import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 13 14 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 14 15 import 'package:sparksocial/src/features/feed/ui/widgets/post/post_overlay.dart'; 15 16 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; ··· 31 32 bool _shouldBlurContent = false; 32 33 List<String> _warningLabels = []; 33 34 bool? _overrideIsLiked; 35 + PostView? _currentPost; 34 36 35 37 @override 36 38 void initState() { ··· 62 64 } 63 65 64 66 Future<void> _handleDoubleTapLike(PostView postData) async { 65 - final isCurrentlyLiked = postData.viewer?.like != null; 67 + final isCurrentlyLiked = _overrideIsLiked ?? (postData.viewer?.like != null); 66 68 67 69 if (isCurrentlyLiked) { 68 70 return; ··· 74 76 }); 75 77 76 78 try { 77 - // Drive SideActionBar via props instead of GlobalKey/stateful method 79 + // Like the post using the same logic as SideActionBar 80 + final newLike = await ref.read(likePostProvider(postData.cid, postData.uri).future); 81 + 82 + // Update the post's viewer field with the new like reference and increment like count 83 + final updatedPost = postData.copyWith( 84 + likeCount: (postData.likeCount ?? 0) + 1, 85 + viewer: postData.viewer?.copyWith(like: newLike.uri) ?? ViewerState(like: newLike.uri, repost: postData.viewer?.repost), 86 + ); 87 + 78 88 if (mounted) { 79 89 setState(() { 80 90 _overrideIsLiked = true; 91 + _currentPost = updatedPost; 81 92 }); 82 93 } 83 94 } catch (e) { ··· 142 153 ); 143 154 } 144 155 145 - final post = snapshot.data!; 156 + final post = _currentPost ?? snapshot.data!; 146 157 147 158 final mainContent = HeartAnimation( 148 159 isAnimating: _isAnimatingHeart,
+1 -1
widgetbook/ios/Flutter/AppFrameworkInfo.plist
··· 21 21 <key>CFBundleVersion</key> 22 22 <string>1.0</string> 23 23 <key>MinimumOSVersion</key> 24 - <string>12.0</string> 24 + <string>13.0</string> 25 25 </dict> 26 26 </plist>
+159
widgetbook/ios/Podfile.lock
··· 1 + PODS: 2 + - audio_waveforms (0.0.1): 3 + - Flutter 4 + - audioplayers_darwin (0.0.1): 5 + - Flutter 6 + - FlutterMacOS 7 + - better_player_plus (1.1.2): 8 + - Cache (~> 6.0.0) 9 + - Flutter 10 + - GCDWebServer 11 + - HLSCachingReverseProxyServer 12 + - PINCache 13 + - Cache (6.0.0) 14 + - camera_avfoundation (0.0.1): 15 + - Flutter 16 + - Flutter (1.0.0) 17 + - flutter_secure_storage (6.0.0): 18 + - Flutter 19 + - fvp (0.35.1): 20 + - Flutter 21 + - FlutterMacOS 22 + - mdk (~> 0.35.0) 23 + - GCDWebServer (3.5.4): 24 + - GCDWebServer/Core (= 3.5.4) 25 + - GCDWebServer/Core (3.5.4) 26 + - HLSCachingReverseProxyServer (0.1.0): 27 + - GCDWebServer (~> 3.5) 28 + - PINCache (>= 3.0.1-beta.3) 29 + - image_picker_ios (0.0.1): 30 + - Flutter 31 + - mdk (0.35.0) 32 + - package_info_plus (0.4.5): 33 + - Flutter 34 + - path_provider_foundation (0.0.1): 35 + - Flutter 36 + - FlutterMacOS 37 + - PINCache (3.0.4): 38 + - PINCache/Arc-exception-safe (= 3.0.4) 39 + - PINCache/Core (= 3.0.4) 40 + - PINCache/Arc-exception-safe (3.0.4): 41 + - PINCache/Core 42 + - PINCache/Core (3.0.4): 43 + - PINOperation (~> 1.2.3) 44 + - PINOperation (1.2.3) 45 + - PostHog (3.35.0) 46 + - posthog_flutter (0.0.1): 47 + - Flutter 48 + - FlutterMacOS 49 + - PostHog (< 4.0.0, >= 3.32.0) 50 + - pro_video_editor (0.0.1): 51 + - Flutter 52 + - shared_preferences_foundation (0.0.1): 53 + - Flutter 54 + - FlutterMacOS 55 + - sqflite_darwin (0.0.4): 56 + - Flutter 57 + - FlutterMacOS 58 + - url_launcher_ios (0.0.1): 59 + - Flutter 60 + - video_player_avfoundation (0.0.1): 61 + - Flutter 62 + - FlutterMacOS 63 + - wakelock_plus (0.0.1): 64 + - Flutter 65 + 66 + DEPENDENCIES: 67 + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) 68 + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) 69 + - better_player_plus (from `.symlinks/plugins/better_player_plus/ios`) 70 + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) 71 + - Flutter (from `Flutter`) 72 + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) 73 + - fvp (from `.symlinks/plugins/fvp/darwin`) 74 + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 75 + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 76 + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 77 + - posthog_flutter (from `.symlinks/plugins/posthog_flutter/ios`) 78 + - pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`) 79 + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 80 + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) 81 + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 82 + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) 83 + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) 84 + 85 + SPEC REPOS: 86 + trunk: 87 + - Cache 88 + - GCDWebServer 89 + - HLSCachingReverseProxyServer 90 + - mdk 91 + - PINCache 92 + - PINOperation 93 + - PostHog 94 + 95 + EXTERNAL SOURCES: 96 + audio_waveforms: 97 + :path: ".symlinks/plugins/audio_waveforms/ios" 98 + audioplayers_darwin: 99 + :path: ".symlinks/plugins/audioplayers_darwin/darwin" 100 + better_player_plus: 101 + :path: ".symlinks/plugins/better_player_plus/ios" 102 + camera_avfoundation: 103 + :path: ".symlinks/plugins/camera_avfoundation/ios" 104 + Flutter: 105 + :path: Flutter 106 + flutter_secure_storage: 107 + :path: ".symlinks/plugins/flutter_secure_storage/ios" 108 + fvp: 109 + :path: ".symlinks/plugins/fvp/darwin" 110 + image_picker_ios: 111 + :path: ".symlinks/plugins/image_picker_ios/ios" 112 + package_info_plus: 113 + :path: ".symlinks/plugins/package_info_plus/ios" 114 + path_provider_foundation: 115 + :path: ".symlinks/plugins/path_provider_foundation/darwin" 116 + posthog_flutter: 117 + :path: ".symlinks/plugins/posthog_flutter/ios" 118 + pro_video_editor: 119 + :path: ".symlinks/plugins/pro_video_editor/ios" 120 + shared_preferences_foundation: 121 + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" 122 + sqflite_darwin: 123 + :path: ".symlinks/plugins/sqflite_darwin/darwin" 124 + url_launcher_ios: 125 + :path: ".symlinks/plugins/url_launcher_ios/ios" 126 + video_player_avfoundation: 127 + :path: ".symlinks/plugins/video_player_avfoundation/darwin" 128 + wakelock_plus: 129 + :path: ".symlinks/plugins/wakelock_plus/ios" 130 + 131 + SPEC CHECKSUMS: 132 + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf 133 + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd 134 + better_player_plus: 3d40145c650bb83dde08f0d593b21a144196769f 135 + Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d 136 + camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 137 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 138 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 139 + fvp: 5a47a325ffa8d63e70a276a6436416ae703206a5 140 + GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 141 + HLSCachingReverseProxyServer: 59935e1e0244ad7f3375d75b5ef46e8eb26ab181 142 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 143 + mdk: baa616b93f696c7066df0e5ebe057badfa9c462b 144 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 145 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 146 + PINCache: d9a87a0ff397acffe9e2f0db972ac14680441158 147 + PINOperation: fb563bcc9c32c26d6c78aaff967d405aa2ee74a7 148 + PostHog: da83ef8bee8c21ccb2b3680054e22c85d352f142 149 + posthog_flutter: 9535ac2d4ab65ccb9ace3886dcc0b3105198bad5 150 + pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 151 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb 152 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 153 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 154 + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a 155 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 156 + 157 + PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e 158 + 159 + COCOAPODS: 1.16.2
+115 -3
widgetbook/ios/Runner.xcodeproj/project.pbxproj
··· 10 10 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 11 11 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 12 12 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 13 + 5FEAB69BB8689796F43F5DAE /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */; }; 13 14 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 14 15 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 15 16 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 16 17 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 18 + A7B551FDDD3B1859739E2ADB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */; }; 17 19 /* End PBXBuildFile section */ 18 20 19 21 /* Begin PBXContainerItemProxy section */ ··· 45 47 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 46 48 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 47 49 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 50 + 46131E15D8B4611453BD1DDB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; 51 + 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 48 52 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 49 53 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 50 54 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 55 + 7E30407E72EF685B2ED96145 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; 56 + 81F90E7337B42F1D05FD81CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; 51 57 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 52 58 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 53 59 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; ··· 55 61 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 56 62 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 57 63 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 64 + B9D0340B3FE2EEFD2C593715 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; 65 + C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 66 + E66A3276723A81A2130301C3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 67 + EDC57EE11678391C675F78EC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 58 68 /* End PBXFileReference section */ 59 69 60 70 /* Begin PBXFrameworksBuildPhase section */ 71 + 28881A095B2C3A17F52DBCFB /* Frameworks */ = { 72 + isa = PBXFrameworksBuildPhase; 73 + buildActionMask = 2147483647; 74 + files = ( 75 + 5FEAB69BB8689796F43F5DAE /* Pods_RunnerTests.framework in Frameworks */, 76 + ); 77 + runOnlyForDeploymentPostprocessing = 0; 78 + }; 61 79 97C146EB1CF9000F007C117D /* Frameworks */ = { 62 80 isa = PBXFrameworksBuildPhase; 63 81 buildActionMask = 2147483647; 64 82 files = ( 83 + A7B551FDDD3B1859739E2ADB /* Pods_Runner.framework in Frameworks */, 65 84 ); 66 85 runOnlyForDeploymentPostprocessing = 0; 67 86 }; ··· 76 95 path = RunnerTests; 77 96 sourceTree = "<group>"; 78 97 }; 98 + 5F8AB1DE738E58354B5F127E /* Pods */ = { 99 + isa = PBXGroup; 100 + children = ( 101 + B9D0340B3FE2EEFD2C593715 /* Pods-Runner.debug.xcconfig */, 102 + E66A3276723A81A2130301C3 /* Pods-Runner.release.xcconfig */, 103 + EDC57EE11678391C675F78EC /* Pods-Runner.profile.xcconfig */, 104 + 46131E15D8B4611453BD1DDB /* Pods-RunnerTests.debug.xcconfig */, 105 + 7E30407E72EF685B2ED96145 /* Pods-RunnerTests.release.xcconfig */, 106 + 81F90E7337B42F1D05FD81CB /* Pods-RunnerTests.profile.xcconfig */, 107 + ); 108 + name = Pods; 109 + path = Pods; 110 + sourceTree = "<group>"; 111 + }; 79 112 9740EEB11CF90186004384FC /* Flutter */ = { 80 113 isa = PBXGroup; 81 114 children = ( ··· 94 127 97C146F01CF9000F007C117D /* Runner */, 95 128 97C146EF1CF9000F007C117D /* Products */, 96 129 331C8082294A63A400263BE5 /* RunnerTests */, 130 + 5F8AB1DE738E58354B5F127E /* Pods */, 131 + C266B9B5E5EC761A721B3EDE /* Frameworks */, 97 132 ); 98 133 sourceTree = "<group>"; 99 134 }; ··· 121 156 path = Runner; 122 157 sourceTree = "<group>"; 123 158 }; 159 + C266B9B5E5EC761A721B3EDE /* Frameworks */ = { 160 + isa = PBXGroup; 161 + children = ( 162 + 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */, 163 + C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */, 164 + ); 165 + name = Frameworks; 166 + sourceTree = "<group>"; 167 + }; 124 168 /* End PBXGroup section */ 125 169 126 170 /* Begin PBXNativeTarget section */ ··· 128 172 isa = PBXNativeTarget; 129 173 buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; 130 174 buildPhases = ( 175 + 2EFAACE07A2FAD3DCD14FFC7 /* [CP] Check Pods Manifest.lock */, 131 176 331C807D294A63A400263BE5 /* Sources */, 132 177 331C807F294A63A400263BE5 /* Resources */, 178 + 28881A095B2C3A17F52DBCFB /* Frameworks */, 133 179 ); 134 180 buildRules = ( 135 181 ); ··· 145 191 isa = PBXNativeTarget; 146 192 buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; 147 193 buildPhases = ( 194 + 6B96FFA8679C39B024B1CBA7 /* [CP] Check Pods Manifest.lock */, 148 195 9740EEB61CF901F6004384FC /* Run Script */, 149 196 97C146EA1CF9000F007C117D /* Sources */, 150 197 97C146EB1CF9000F007C117D /* Frameworks */, 151 198 97C146EC1CF9000F007C117D /* Resources */, 152 199 9705A1C41CF9048500538489 /* Embed Frameworks */, 153 200 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 201 + 3C4DB3EBE9E00FC5C43C6516 /* [CP] Embed Pods Frameworks */, 154 202 ); 155 203 buildRules = ( 156 204 ); ··· 222 270 /* End PBXResourcesBuildPhase section */ 223 271 224 272 /* Begin PBXShellScriptBuildPhase section */ 273 + 2EFAACE07A2FAD3DCD14FFC7 /* [CP] Check Pods Manifest.lock */ = { 274 + isa = PBXShellScriptBuildPhase; 275 + buildActionMask = 2147483647; 276 + files = ( 277 + ); 278 + inputFileListPaths = ( 279 + ); 280 + inputPaths = ( 281 + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 282 + "${PODS_ROOT}/Manifest.lock", 283 + ); 284 + name = "[CP] Check Pods Manifest.lock"; 285 + outputFileListPaths = ( 286 + ); 287 + outputPaths = ( 288 + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", 289 + ); 290 + runOnlyForDeploymentPostprocessing = 0; 291 + shellPath = /bin/sh; 292 + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 293 + showEnvVarsInLog = 0; 294 + }; 225 295 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 226 296 isa = PBXShellScriptBuildPhase; 227 297 alwaysOutOfDate = 1; ··· 238 308 shellPath = /bin/sh; 239 309 shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 240 310 }; 311 + 3C4DB3EBE9E00FC5C43C6516 /* [CP] Embed Pods Frameworks */ = { 312 + isa = PBXShellScriptBuildPhase; 313 + buildActionMask = 2147483647; 314 + files = ( 315 + ); 316 + inputFileListPaths = ( 317 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 318 + ); 319 + name = "[CP] Embed Pods Frameworks"; 320 + outputFileListPaths = ( 321 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 322 + ); 323 + runOnlyForDeploymentPostprocessing = 0; 324 + shellPath = /bin/sh; 325 + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 326 + showEnvVarsInLog = 0; 327 + }; 328 + 6B96FFA8679C39B024B1CBA7 /* [CP] Check Pods Manifest.lock */ = { 329 + isa = PBXShellScriptBuildPhase; 330 + buildActionMask = 2147483647; 331 + files = ( 332 + ); 333 + inputFileListPaths = ( 334 + ); 335 + inputPaths = ( 336 + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 337 + "${PODS_ROOT}/Manifest.lock", 338 + ); 339 + name = "[CP] Check Pods Manifest.lock"; 340 + outputFileListPaths = ( 341 + ); 342 + outputPaths = ( 343 + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", 344 + ); 345 + runOnlyForDeploymentPostprocessing = 0; 346 + shellPath = /bin/sh; 347 + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 348 + showEnvVarsInLog = 0; 349 + }; 241 350 9740EEB61CF901F6004384FC /* Run Script */ = { 242 351 isa = PBXShellScriptBuildPhase; 243 352 alwaysOutOfDate = 1; ··· 346 455 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 347 456 GCC_WARN_UNUSED_FUNCTION = YES; 348 457 GCC_WARN_UNUSED_VARIABLE = YES; 349 - IPHONEOS_DEPLOYMENT_TARGET = 12.0; 458 + IPHONEOS_DEPLOYMENT_TARGET = 13.0; 350 459 MTL_ENABLE_DEBUG_INFO = NO; 351 460 SDKROOT = iphoneos; 352 461 SUPPORTED_PLATFORMS = iphoneos; ··· 378 487 }; 379 488 331C8088294A63A400263BE5 /* Debug */ = { 380 489 isa = XCBuildConfiguration; 490 + baseConfigurationReference = 46131E15D8B4611453BD1DDB /* Pods-RunnerTests.debug.xcconfig */; 381 491 buildSettings = { 382 492 BUNDLE_LOADER = "$(TEST_HOST)"; 383 493 CODE_SIGN_STYLE = Automatic; ··· 395 505 }; 396 506 331C8089294A63A400263BE5 /* Release */ = { 397 507 isa = XCBuildConfiguration; 508 + baseConfigurationReference = 7E30407E72EF685B2ED96145 /* Pods-RunnerTests.release.xcconfig */; 398 509 buildSettings = { 399 510 BUNDLE_LOADER = "$(TEST_HOST)"; 400 511 CODE_SIGN_STYLE = Automatic; ··· 410 521 }; 411 522 331C808A294A63A400263BE5 /* Profile */ = { 412 523 isa = XCBuildConfiguration; 524 + baseConfigurationReference = 81F90E7337B42F1D05FD81CB /* Pods-RunnerTests.profile.xcconfig */; 413 525 buildSettings = { 414 526 BUNDLE_LOADER = "$(TEST_HOST)"; 415 527 CODE_SIGN_STYLE = Automatic; ··· 472 584 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 473 585 GCC_WARN_UNUSED_FUNCTION = YES; 474 586 GCC_WARN_UNUSED_VARIABLE = YES; 475 - IPHONEOS_DEPLOYMENT_TARGET = 12.0; 587 + IPHONEOS_DEPLOYMENT_TARGET = 13.0; 476 588 MTL_ENABLE_DEBUG_INFO = YES; 477 589 ONLY_ACTIVE_ARCH = YES; 478 590 SDKROOT = iphoneos; ··· 523 635 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 524 636 GCC_WARN_UNUSED_FUNCTION = YES; 525 637 GCC_WARN_UNUSED_VARIABLE = YES; 526 - IPHONEOS_DEPLOYMENT_TARGET = 12.0; 638 + IPHONEOS_DEPLOYMENT_TARGET = 13.0; 527 639 MTL_ENABLE_DEBUG_INFO = NO; 528 640 SDKROOT = iphoneos; 529 641 SUPPORTED_PLATFORMS = iphoneos;
+3
widgetbook/ios/Runner.xcworkspace/contents.xcworkspacedata
··· 4 4 <FileRef 5 5 location = "group:Runner.xcodeproj"> 6 6 </FileRef> 7 + <FileRef 8 + location = "group:Pods/Pods.xcodeproj"> 9 + </FileRef> 7 10 </Workspace>
+94
widgetbook/lib/atoms/avatar_stack.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:sparksocial/src/core/design_system/components/atoms/avatar_stack.dart'; 3 + import 'package:widgetbook/widgetbook.dart'; 4 + import 'package:widgetbook_annotation/widgetbook_annotation.dart'; 5 + 6 + const _demoImageBase = 'https://picsum.photos/seed'; 7 + 8 + List<AvatarData> _generateAvatars(int count) { 9 + return List.generate( 10 + count, 11 + (index) => AvatarData( 12 + imageUrl: '$_demoImageBase/user$index/200/200', 13 + username: 'user$index', 14 + ), 15 + ); 16 + } 17 + 18 + @UseCase(name: 'default', type: AvatarStack) 19 + Widget buildAvatarStackDefaultUseCase(BuildContext context) { 20 + final avatarCount = context.knobs.int.slider( 21 + label: 'Avatar count', 22 + initialValue: 5, 23 + min: 1, 24 + max: 10, 25 + ); 26 + 27 + return Center(child: AvatarStack(avatars: _generateAvatars(avatarCount))); 28 + } 29 + 30 + @UseCase(name: 'customized', type: AvatarStack) 31 + Widget buildAvatarStackCustomizedUseCase(BuildContext context) { 32 + final avatarCount = context.knobs.int.slider( 33 + label: 'Avatar count', 34 + initialValue: 5, 35 + min: 1, 36 + max: 10, 37 + ); 38 + 39 + final largeAvatarCount = context.knobs.int.slider( 40 + label: 'Large avatar count', 41 + initialValue: 2, 42 + min: 0, 43 + max: 5, 44 + ); 45 + 46 + final largeSize = context.knobs.double.slider( 47 + label: 'Large size', 48 + initialValue: 36, 49 + min: 20, 50 + max: 60, 51 + ); 52 + 53 + final smallSize = context.knobs.double.slider( 54 + label: 'Small size', 55 + initialValue: 15, 56 + min: 12, 57 + max: 40, 58 + ); 59 + 60 + final largeOverlap = context.knobs.double.slider( 61 + label: 'Large overlap', 62 + initialValue: 12, 63 + min: 0, 64 + max: 30, 65 + ); 66 + 67 + final smallOverlap = context.knobs.double.slider( 68 + label: 'Small overlap', 69 + initialValue: 0, 70 + min: 0, 71 + max: 20, 72 + ); 73 + 74 + return Center( 75 + child: AvatarStack( 76 + avatars: _generateAvatars(avatarCount), 77 + largeAvatarCount: largeAvatarCount, 78 + largeSize: largeSize, 79 + smallSize: smallSize, 80 + largeOverlap: largeOverlap, 81 + smallOverlap: smallOverlap, 82 + ), 83 + ); 84 + } 85 + 86 + @UseCase(name: 'empty', type: AvatarStack) 87 + Widget buildAvatarStackEmptyUseCase(BuildContext context) { 88 + return const Center(child: AvatarStack(avatars: [])); 89 + } 90 + 91 + @UseCase(name: 'single avatar', type: AvatarStack) 92 + Widget buildAvatarStackSingleUseCase(BuildContext context) { 93 + return Center(child: AvatarStack(avatars: _generateAvatars(1))); 94 + }
+74
widgetbook/lib/organisms/side_action_bar.dart
··· 13 13 label: 'commentCount', 14 14 initialValue: '2.4k', 15 15 ); 16 + final repostCount = context.knobs.string( 17 + label: 'repostCount', 18 + initialValue: '1.2k', 19 + ); 16 20 final curateCount = context.knobs.string( 17 21 label: 'curateCount', 18 22 initialValue: '129', ··· 22 26 initialValue: '98', 23 27 ); 24 28 final isLiked = context.knobs.boolean(label: 'isLiked', initialValue: true); 29 + final isReposted = context.knobs.boolean( 30 + label: 'isReposted', 31 + initialValue: false, 32 + ); 25 33 final isCurated = context.knobs.boolean( 26 34 label: 'isCurated', 27 35 initialValue: false, ··· 31 39 child: SparkSideActionBar( 32 40 likeCount: likeCount, 33 41 commentCount: commentCount, 42 + repostCount: repostCount, 34 43 curateCount: curateCount, 35 44 shareCount: shareCount, 36 45 isLiked: isLiked, 46 + isReposted: isReposted, 37 47 isCurated: isCurated, 38 48 onLike: () => print('Like tapped (current: $isLiked)'), 39 49 onComment: () => print('Comment tapped'), 50 + onRepost: () => print('Repost tapped (current: $isReposted)'), 40 51 onCurate: () => print('Curate tapped (current: $isCurated)'), 41 52 onShare: () => print('Share tapped'), 42 53 ), ··· 46 57 @UseCase(name: 'empty_counts', type: SparkSideActionBar) 47 58 Widget buildSparkSideActionBarEmptyCountsUseCase(BuildContext context) { 48 59 final isLiked = context.knobs.boolean(label: 'isLiked', initialValue: false); 60 + final isReposted = context.knobs.boolean( 61 + label: 'isReposted', 62 + initialValue: false, 63 + ); 49 64 final isCurated = context.knobs.boolean( 50 65 label: 'isCurated', 51 66 initialValue: false, ··· 55 70 child: SparkSideActionBar( 56 71 likeCount: '', 57 72 commentCount: '', 73 + repostCount: '', 58 74 curateCount: '', 59 75 shareCount: '', 60 76 isLiked: isLiked, 77 + isReposted: isReposted, 61 78 isCurated: isCurated, 62 79 onLike: () => print('Like tapped'), 63 80 onComment: () => print('Comment tapped'), 81 + onRepost: () => print('Repost tapped'), 64 82 onCurate: () => print('Curate tapped'), 65 83 onShare: () => print('Share tapped'), 66 84 ), ··· 73 91 label: 'startLiked', 74 92 initialValue: false, 75 93 ); 94 + final startReposted = context.knobs.boolean( 95 + label: 'startReposted', 96 + initialValue: false, 97 + ); 76 98 final startCurated = context.knobs.boolean( 77 99 label: 'startCurated', 78 100 initialValue: false, ··· 91 113 max: 5000, 92 114 divisions: 50, 93 115 ); 116 + final repostCountStart = context.knobs.int.slider( 117 + label: 'repostStart', 118 + initialValue: 45, 119 + min: 0, 120 + max: 5000, 121 + divisions: 50, 122 + ); 94 123 final curateCountStart = context.knobs.int.slider( 95 124 label: 'curateStart', 96 125 initialValue: 12, ··· 109 138 return StatefulBuilder( 110 139 builder: (ctx, setState) { 111 140 var liked = startLiked; 141 + var reposted = startReposted; 112 142 var curated = startCurated; 113 143 var likeCount = likeCountStart; 144 + var repostCount = repostCountStart; 114 145 var curateCount = curateCountStart; 115 146 116 147 return _ScaffoldedBackground( 117 148 child: SparkSideActionBar( 118 149 likeCount: likeCount.toString(), 119 150 commentCount: commentCountStart.toString(), 151 + repostCount: repostCount.toString(), 120 152 curateCount: curateCount.toString(), 121 153 shareCount: shareCountStart.toString(), 122 154 isLiked: liked, 155 + isReposted: reposted, 123 156 isCurated: curated, 124 157 onLike: () { 125 158 setState(() { ··· 128 161 }); 129 162 print('Like toggled -> $liked (count: $likeCount)'); 130 163 }, 164 + onRepost: () { 165 + setState(() { 166 + reposted = !reposted; 167 + repostCount += reposted ? 1 : -1; 168 + }); 169 + print('Repost toggled -> $reposted (count: $repostCount)'); 170 + }, 131 171 onCurate: () { 132 172 setState(() { 133 173 curated = !curated; ··· 160 200 child: const SparkSideActionBar( 161 201 likeCount: '12', 162 202 commentCount: '3', 203 + repostCount: '5', 163 204 curateCount: '4', 164 205 shareCount: '1', 165 206 isLiked: true, 207 + isReposted: false, 166 208 isCurated: true, 167 209 ), 168 210 ), ··· 185 227 max: 500, 186 228 divisions: 50, 187 229 ); 230 + final repostCount = context.knobs.int.slider( 231 + label: 'reposts', 232 + initialValue: 12, 233 + min: 0, 234 + max: 500, 235 + divisions: 50, 236 + ); 188 237 final shareCount = context.knobs.int.slider( 189 238 label: 'shares', 190 239 initialValue: 2, ··· 196 245 return StatefulBuilder( 197 246 builder: (ctx, setState) { 198 247 var curated = false; 248 + var reposted = false; 199 249 var curateCount = 5; 200 250 return _ScaffoldedBackground( 201 251 child: SparkSideActionBar( 202 252 likeCount: likeCount.toString(), 203 253 commentCount: commentCount.toString(), 254 + repostCount: repostCount.toString(), 204 255 curateCount: curateCount.toString(), 205 256 shareCount: shareCount.toString(), 206 257 isLiked: false, 258 + isReposted: reposted, 207 259 isCurated: curated, 208 260 curateDestinations: [ 209 261 CurateDestination( ··· 219 271 onSelected: () => print('Ideas selected'), 220 272 ), 221 273 ], 274 + onRepost: () { 275 + setState(() { 276 + reposted = !reposted; 277 + }); 278 + print('Reposted! $reposted'); 279 + }, 222 280 onCurate: () { 223 281 setState(() { 224 282 curated = true; ··· 253 311 initialValue: '156', 254 312 ); 255 313 314 + final repostCount = context.knobs.string( 315 + label: 'repostCount', 316 + initialValue: '89', 317 + ); 318 + 256 319 final curateCount = context.knobs.string( 257 320 label: 'curateCount', 258 321 initialValue: '42', ··· 264 327 ); 265 328 266 329 final isLiked = context.knobs.boolean(label: 'isLiked', initialValue: true); 330 + final isReposted = context.knobs.boolean( 331 + label: 'isReposted', 332 + initialValue: false, 333 + ); 267 334 final isCurated = context.knobs.boolean( 268 335 label: 'isCurated', 269 336 initialValue: false, ··· 272 339 return StatefulBuilder( 273 340 builder: (ctx, setState) { 274 341 var liked = isLiked; 342 + var reposted = isReposted; 275 343 var curated = isCurated; 276 344 277 345 return Scaffold( ··· 316 384 child: SparkSideActionBar( 317 385 likeCount: likeCount, 318 386 commentCount: commentCount, 387 + repostCount: repostCount, 319 388 curateCount: curateCount, 320 389 shareCount: shareCount, 321 390 isLiked: liked, 391 + isReposted: reposted, 322 392 isCurated: curated, 323 393 curateDestinations: [ 324 394 CurateDestination( ··· 339 409 print('Like toggled: $liked'); 340 410 }, 341 411 onComment: () => print('Comment tapped'), 412 + onRepost: () { 413 + setState(() => reposted = !reposted); 414 + print('Repost toggled: $reposted'); 415 + }, 342 416 onCurate: () { 343 417 setState(() => curated = true); 344 418 print('Curated!');