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

Configure Feed

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

feat: stories in profile

+156 -81
+53 -27
lib/src/core/design_system/components/molecules/profile_avatar.dart
··· 28 28 Widget build(BuildContext context) { 29 29 final theme = Theme.of(context); 30 30 final isDarkMode = theme.brightness == Brightness.dark; 31 + final ringWidth = (size * 0.03).clamp(1.0, 2.0).toDouble(); 32 + final ringGap = (size * 0.04).clamp(1.5, 3.0).toDouble(); 33 + final avatarSize = hasStories 34 + ? (size - (2 * (ringWidth + ringGap))).clamp(0.0, size).toDouble() 35 + : size; 31 36 32 - final Widget avatarWidget; 33 - if (avatarUrl != null && avatarUrl!.isNotEmpty) { 34 - avatarWidget = ClipOval( 35 - child: CachedNetworkImage( 36 - fadeInDuration: Duration.zero, 37 - imageUrl: avatarUrl!, 38 - width: size, 39 - height: size, 40 - fit: BoxFit.cover, 41 - placeholder: (context, url) => _buildPlaceholder(context, isDarkMode), 42 - errorWidget: (context, url, error) => 43 - _buildPlaceholder(context, isDarkMode), 44 - ), 45 - ); 46 - } else { 47 - avatarWidget = _buildPlaceholder(context, isDarkMode); 48 - } 37 + final avatarWidget = _buildAvatarImage( 38 + context, 39 + isDarkMode: isDarkMode, 40 + avatarSize: avatarSize, 41 + ); 49 42 50 43 return Stack( 51 44 children: [ ··· 66 59 shape: BoxShape.circle, 67 60 ), 68 61 child: hasStories 69 - ? Container( 70 - margin: const EdgeInsets.all(2), 71 - decoration: const BoxDecoration( 72 - shape: BoxShape.circle, 73 - color: Colors.black, 62 + ? Padding( 63 + padding: EdgeInsets.all(ringWidth), 64 + child: Container( 65 + decoration: BoxDecoration( 66 + shape: BoxShape.circle, 67 + color: theme.scaffoldBackgroundColor, 68 + ), 69 + child: Padding( 70 + padding: EdgeInsets.all(ringGap), 71 + child: avatarWidget, 72 + ), 74 73 ), 75 - child: Center(child: avatarWidget), 76 74 ) 77 75 : Center(child: avatarWidget), 78 76 ), ··· 108 106 ); 109 107 } 110 108 111 - Widget _buildPlaceholder(BuildContext context, bool isDarkMode) { 109 + Widget _buildAvatarImage( 110 + BuildContext context, { 111 + required bool isDarkMode, 112 + required double avatarSize, 113 + }) { 114 + if (avatarUrl != null && avatarUrl!.isNotEmpty) { 115 + return ClipOval( 116 + child: CachedNetworkImage( 117 + fadeInDuration: Duration.zero, 118 + imageUrl: avatarUrl!, 119 + width: avatarSize, 120 + height: avatarSize, 121 + fit: BoxFit.cover, 122 + placeholder: (context, url) => 123 + _buildPlaceholder(context, isDarkMode, avatarSize), 124 + errorWidget: (context, url, error) => 125 + _buildPlaceholder(context, isDarkMode, avatarSize), 126 + ), 127 + ); 128 + } 129 + 130 + return _buildPlaceholder(context, isDarkMode, avatarSize); 131 + } 132 + 133 + Widget _buildPlaceholder( 134 + BuildContext context, 135 + bool isDarkMode, 136 + double avatarSize, 137 + ) { 112 138 return Container( 113 - width: size, 114 - height: size, 139 + width: avatarSize, 140 + height: avatarSize, 115 141 decoration: BoxDecoration( 116 142 color: isDarkMode ? AppColors.darkPurple : AppColors.lightLavender, 117 143 shape: BoxShape.circle, ··· 119 145 child: Center( 120 146 child: Icon( 121 147 FluentIcons.person_24_regular, 122 - size: size * 0.44, 148 + size: avatarSize * 0.44, 123 149 color: isDarkMode ? AppColors.textLight : AppColors.textSecondary, 124 150 ), 125 151 ),
+15 -39
lib/src/core/design_system/components/molecules/profile_card.dart
··· 1 - import 'package:cached_network_image/cached_network_image.dart'; 2 1 import 'package:flutter/material.dart'; 3 2 import 'package:spark/src/core/design_system/components/atoms/toggles/follow_button.dart'; 3 + import 'package:spark/src/core/design_system/components/molecules/profile_avatar.dart'; 4 4 import 'package:spark/src/core/design_system/tokens/colors.dart'; 5 5 import 'package:spark/src/core/design_system/tokens/shapes.dart'; 6 6 import 'package:spark/src/core/design_system/tokens/typography.dart'; ··· 18 18 this.showFollowButton = true, 19 19 this.description, 20 20 this.onTap, 21 + this.hasStories = false, 22 + this.onAvatarTap, 21 23 super.key, 22 24 }); 23 25 ··· 34 36 VoidCallback? onUnblock, 35 37 bool showFollowButton = true, 36 38 VoidCallback? onTap, 39 + bool hasStories = false, 40 + VoidCallback? onAvatarTap, 37 41 Key? key, 38 42 }) : this( 39 43 imageUrl: imageUrl, ··· 47 51 showFollowButton: showFollowButton, 48 52 description: description, 49 53 onTap: onTap, 54 + hasStories: hasStories, 55 + onAvatarTap: onAvatarTap, 50 56 key: key, 51 57 ); 52 58 ··· 61 67 final bool showFollowButton; 62 68 final String? description; 63 69 final VoidCallback? onTap; 70 + final bool hasStories; 71 + final VoidCallback? onAvatarTap; 64 72 65 73 @override 66 74 Widget build(BuildContext context) { ··· 91 99 child: Row( 92 100 crossAxisAlignment: CrossAxisAlignment.start, 93 101 children: [ 94 - ClipRRect( 95 - borderRadius: BorderRadius.circular(100), 96 - child: imageUrl.isNotEmpty 97 - ? CachedNetworkImage( 98 - fadeInDuration: Duration.zero, 99 - imageUrl: imageUrl, 100 - width: 36, 101 - height: 36, 102 - fit: BoxFit.cover, 103 - errorWidget: (context, url, error) => Container( 104 - width: 36, 105 - height: 36, 106 - color: isDark 107 - ? AppColors.grey600 108 - : AppColors.grey300, 109 - child: Icon( 110 - Icons.person, 111 - size: 20, 112 - color: isDark 113 - ? AppColors.grey400 114 - : AppColors.grey600, 115 - ), 116 - ), 117 - ) 118 - : Container( 119 - width: 36, 120 - height: 36, 121 - color: isDark 122 - ? AppColors.grey600 123 - : AppColors.grey300, 124 - child: Icon( 125 - Icons.person, 126 - size: 20, 127 - color: isDark 128 - ? AppColors.grey400 129 - : AppColors.grey600, 130 - ), 131 - ), 102 + ProfileAvatar( 103 + avatarUrl: imageUrl.isNotEmpty ? imageUrl : null, 104 + displayName: userName, 105 + size: 36, 106 + hasStories: hasStories, 107 + onTap: onAvatarTap ?? onTap, 132 108 ), 133 109 const SizedBox(width: 10), 134 110 Expanded(
+1 -1
lib/src/core/design_system/components/organisms/bottom_nav_bar.dart
··· 239 239 height: 34, 240 240 decoration: BoxDecoration( 241 241 shape: BoxShape.circle, 242 - border: Border.all(color: Colors.white), 242 + border: isSelected ? Border.all(color: Colors.white) : null, 243 243 image: image is AssetImage 244 244 ? null 245 245 : DecorationImage(image: image, fit: BoxFit.cover),
+1 -1
lib/src/core/network/atproto/data/models/actor_models.dart
··· 77 77 // indexedAt, createdAt 78 78 ActorViewer? viewer, 79 79 List<Label>? labels, 80 - // no stories here, for some reason 80 + List<RepoStrongRef>? stories, 81 81 }) = _ProfileView; 82 82 const ProfileView._(); 83 83
+9 -2
lib/src/core/routing/app_router.dart
··· 30 30 31 31 try { 32 32 await authRepository.initializationComplete; 33 + final hadSavedSession = authRepository.did?.isNotEmpty ?? false; 33 34 final isSessionValid = await authRepository.validateSession(); 34 35 35 36 if (!isSessionValid) { 36 - _logger.i('Redirecting to register because the user is not signed in'); 37 - resolver.redirectUntil(const RegisterRoute()); 37 + _logger.i( 38 + hadSavedSession 39 + ? 'Redirecting to login because the saved session is no longer valid' 40 + : 'Redirecting to register because the user is not signed in', 41 + ); 42 + resolver.redirectUntil( 43 + hadSavedSession ? const LoginRoute() : const RegisterRoute(), 44 + ); 38 45 return; 39 46 } 40 47
+9 -8
lib/src/features/posting/ui/widgets/mention_input_field.dart
··· 265 265 byteEnd: byteEnd, 266 266 ); 267 267 268 - final dedupedMentions = widget.controller.mentions 269 - .where( 270 - (existing) => 271 - existing.byteStart != mention.byteStart || 272 - existing.byteEnd != mention.byteEnd, 273 - ) 274 - .toList() 275 - ..add(mention); 268 + final dedupedMentions = 269 + widget.controller.mentions 270 + .where( 271 + (existing) => 272 + existing.byteStart != mention.byteStart || 273 + existing.byteEnd != mention.byteEnd, 274 + ) 275 + .toList() 276 + ..add(mention); 276 277 widget.controller.replaceMentions(dedupedMentions); 277 278 widget.onMentionsChanged(widget.controller.mentions); 278 279
-1
lib/src/features/profile/ui/pages/profile_page.dart
··· 608 608 displayName: profile.displayName, 609 609 avatar: profile.avatar, 610 610 viewer: profile.viewer, 611 - stories: profile.stories, 612 611 ); 613 612 614 613 if (mounted) {
+7
lib/src/features/profile/ui/widgets/user_list_view.dart
··· 7 7 import 'package:spark/src/core/routing/app_router.dart'; 8 8 import 'package:spark/src/features/profile/providers/user_list_provider.dart'; 9 9 import 'package:spark/src/features/profile/ui/pages/user_list_page.dart'; 10 + import 'package:spark/src/features/stories/utils/story_navigation.dart'; 10 11 11 12 class UserListView extends ConsumerWidget { 12 13 final List<ProfileView> users; ··· 56 57 ); 57 58 } 58 59 final user = users[index]; 60 + final hasStories = user.stories?.isNotEmpty ?? false; 59 61 return Padding( 60 62 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 61 63 child: ProfileCard( ··· 77 79 showFollowButton: !ref 78 80 .read(userListProvider(did: did, type: type).notifier) 79 81 .isCurrentUser(user.did), 82 + hasStories: hasStories, 83 + onAvatarTap: hasStories 84 + ? () => 85 + openStoriesForProfile(context, user, source: 'user list') 86 + : null, 80 87 onTap: () => context.router.push( 81 88 ProfileRoute( 82 89 did: user.did,
+10
lib/src/features/search/ui/pages/user_results.dart
··· 6 6 import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 7 7 import 'package:spark/src/core/routing/app_router.dart'; 8 8 import 'package:spark/src/features/search/providers/search_provider.dart'; 9 + import 'package:spark/src/features/stories/utils/story_navigation.dart'; 9 10 10 11 class UserResults extends ConsumerStatefulWidget { 11 12 const UserResults({super.key}); ··· 184 185 185 186 // Check if the user is being followed 186 187 final isFollowing = actor.viewer?.following != null; 188 + final hasStories = actor.stories?.isNotEmpty ?? false; 187 189 188 190 return Padding( 189 191 padding: const EdgeInsets.only(bottom: 8), ··· 204 206 showFollowButton: !ref 205 207 .read(searchProvider.notifier) 206 208 .isCurrentUser(actor.did), 209 + hasStories: hasStories, 210 + onAvatarTap: hasStories 211 + ? () => openStoriesForProfile( 212 + context, 213 + actor, 214 + source: 'user search', 215 + ) 216 + : null, 207 217 onTap: () { 208 218 if (actor.did.isNotEmpty) { 209 219 context.router.push(
+1 -2
lib/src/features/stories/ui/pages/author_stories_page.dart
··· 351 351 child: Container( 352 352 width: 40, 353 353 height: 40, 354 - decoration: BoxDecoration( 354 + decoration: const BoxDecoration( 355 355 shape: BoxShape.circle, 356 - border: Border.all(color: Colors.white, width: 2), 357 356 ), 358 357 child: ClipOval( 359 358 child: CachedNetworkImage(
+50
lib/src/features/stories/utils/story_navigation.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:get_it/get_it.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 5 + import 'package:spark/src/core/network/atproto/data/repositories/story_repository.dart'; 6 + import 'package:spark/src/core/routing/app_router.dart'; 7 + import 'package:spark/src/core/utils/logging/log_service.dart'; 8 + import 'package:spark/src/core/utils/logging/logger.dart'; 9 + 10 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 11 + 'StoryNavigation', 12 + ); 13 + 14 + /// Fetches and opens the story viewer for a [ProfileView] that has stories. 15 + /// No-ops if the user has no stories. 16 + Future<void> openStoriesForProfile( 17 + BuildContext context, 18 + ProfileView user, { 19 + String source = 'unknown', 20 + }) async { 21 + if (user.stories?.isEmpty ?? true) return; 22 + 23 + try { 24 + final storyUris = user.stories!.map((story) => story.uri).toList(); 25 + if (storyUris.isEmpty) return; 26 + 27 + final storyRepository = GetIt.instance<StoryRepository>(); 28 + final stories = await storyRepository.getStoryViews(storyUris); 29 + if (stories.isEmpty || !context.mounted) return; 30 + 31 + stories.sort((a, b) => a.indexedAt.compareTo(b.indexedAt)); 32 + final authorBasic = ProfileViewBasic( 33 + did: user.did, 34 + handle: user.handle, 35 + displayName: user.displayName, 36 + avatar: user.avatar, 37 + viewer: user.viewer, 38 + ); 39 + 40 + context.router.push( 41 + AllStoriesRoute(storiesByAuthor: {authorBasic: stories}), 42 + ); 43 + } catch (e, s) { 44 + _logger.e( 45 + 'Failed to open stories from $source for ${user.did}', 46 + error: e, 47 + stackTrace: s, 48 + ); 49 + } 50 + }