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

profile fixes

+351 -73
+20 -13
lib/main.dart
··· 13 13 import 'screens/login_screen.dart'; 14 14 import 'screens/auth_prompt_screen.dart'; 15 15 import 'services/auth_service.dart'; 16 + import 'services/profile_service.dart'; 16 17 17 18 void main() { 18 19 WidgetsFlutterBinding.ensureInitialized(); ··· 35 36 providers: [ 36 37 ChangeNotifierProvider(create: (_) => NavigationProvider()), 37 38 ChangeNotifierProvider(create: (_) => AuthService()), 39 + ChangeNotifierProxyProvider<AuthService, ProfileService>( 40 + create: (context) => ProfileService(context.read<AuthService>()), 41 + update: (_, authService, previousProfileService) => 42 + previousProfileService ?? ProfileService(authService), 43 + ), 38 44 ], 39 45 child: CupertinoApp( 40 46 title: 'Spark', ··· 68 74 @override 69 75 Widget build(BuildContext context) { 70 76 final navigationProvider = Provider.of<NavigationProvider>(context); 77 + final authService = Provider.of<AuthService>(context); 71 78 final bool isHomePage = navigationProvider.currentIndex == 0; 72 79 73 80 // Creating the list of screens for navigation ··· 76 83 const SearchScreen(), 77 84 const SizedBox.shrink(), // Placeholder for create button 78 85 const MessagesScreen(), 79 - const ProfileScreen(), 86 + ProfileScreen(did: authService.session?.did), 80 87 ]; 81 88 82 89 return CupertinoPageScaffold( ··· 112 119 mainAxisAlignment: MainAxisAlignment.spaceAround, 113 120 children: [ 114 121 _buildNavItem( 115 - context, 116 - 0, 117 - 'Home', 118 - Ionicons.home_outline, 122 + context, 123 + 0, 124 + 'Home', 125 + Ionicons.home_outline, 119 126 Ionicons.home, 120 127 ), 121 128 _buildNavItem( 122 - context, 123 - 1, 124 - 'Discover', 125 - Ionicons.compass_outline, 129 + context, 130 + 1, 131 + 'Discover', 132 + Ionicons.compass_outline, 126 133 Ionicons.compass, 127 134 ), 128 135 _buildCreateButton(context), 129 136 _buildNavItem( 130 - context, 131 - 3, 132 - 'Messages', 133 - Ionicons.chatbubble_outline, 137 + context, 138 + 3, 139 + 'Messages', 140 + Ionicons.chatbubble_outline, 134 141 Ionicons.chatbubble, 135 142 ), 136 143 _buildNavItem(
+225 -55
lib/screens/profile_screen.dart
··· 10 10 import '../widgets/profile/videos_grid.dart'; 11 11 import '../widgets/profile/early_supporter_sheet.dart'; 12 12 import '../services/auth_service.dart'; 13 + import '../services/profile_service.dart'; 13 14 import 'auth_prompt_screen.dart'; 15 + import 'package:cached_network_image/cached_network_image.dart'; 14 16 15 17 class ProfileScreen extends StatefulWidget { 16 - const ProfileScreen({super.key}); 18 + final String? did; // DID of the profile to show, null means current user 19 + 20 + const ProfileScreen({this.did, super.key}); 17 21 18 22 @override 19 23 State<ProfileScreen> createState() => _ProfileScreenState(); ··· 26 30 // Flags for special badges 27 31 final bool _isEarlySupporter = true; 28 32 33 + @override 34 + void initState() { 35 + super.initState(); 36 + _loadProfile(); 37 + } 38 + 39 + Future<void> _loadProfile() async { 40 + try { 41 + final profileService = Provider.of<ProfileService>(context, listen: false); 42 + final authService = Provider.of<AuthService>(context, listen: false); 43 + 44 + // If no DID is provided, use the current user's DID 45 + final targetDid = widget.did ?? authService.session?.did; 46 + 47 + if (targetDid == null) { 48 + profileService.clearError(); // Clear any existing errors 49 + return; 50 + } 51 + 52 + await profileService.getProfile(targetDid); 53 + 54 + // No need to setState here as we'll be listening to profileService changes 55 + } catch (e) { 56 + // Log any unexpected errors that might occur 57 + print('Unexpected error in _loadProfile: $e'); 58 + } 59 + } 60 + 29 61 void _showEarlySupporterInfo(BuildContext context) { 30 62 showCupertinoModalPopup( 31 63 context: context, ··· 53 85 } 54 86 } 55 87 88 + bool _isCurrentUser(Map<String, dynamic>? profileData) { 89 + if (profileData == null) return false; 90 + final authService = Provider.of<AuthService>(context, listen: false); 91 + return authService.isAuthenticated && 92 + authService.session?.did == profileData['did']; 93 + } 94 + 56 95 @override 57 96 Widget build(BuildContext context) { 58 97 final brightness = MediaQuery.of(context).platformBrightness; 59 98 final isDarkMode = brightness == Brightness.dark; 60 99 final authService = Provider.of<AuthService>(context); 100 + final profileService = Provider.of<ProfileService>(context); // Listen to changes 61 101 final isAuthenticated = authService.isAuthenticated; 62 102 103 + // Get profile data from service 104 + final profileData = profileService.profile; 105 + 63 106 // Show auth prompt if needed 64 107 if (_showAuthPrompt) { 65 108 return AuthPromptScreen( ··· 71 114 ); 72 115 } 73 116 117 + // Show loading indicator 118 + if (profileService.isLoading) { 119 + return CupertinoPageScaffold( 120 + backgroundColor: AppTheme.getBackgroundColor(context, false), 121 + navigationBar: CupertinoNavigationBar( 122 + middle: const Text( 123 + 'Profile', 124 + style: TextStyle( 125 + fontWeight: FontWeight.bold, 126 + fontSize: 18, 127 + ), 128 + ), 129 + backgroundColor: isDarkMode ? AppColors.deepPurple : AppColors.background, 130 + ), 131 + child: const Center( 132 + child: CupertinoActivityIndicator(), 133 + ), 134 + ); 135 + } 136 + 137 + // Show error message 138 + if (profileService.error != null) { 139 + return CupertinoPageScaffold( 140 + backgroundColor: AppTheme.getBackgroundColor(context, false), 141 + navigationBar: CupertinoNavigationBar( 142 + middle: const Text( 143 + 'Profile', 144 + style: TextStyle( 145 + fontWeight: FontWeight.bold, 146 + fontSize: 18, 147 + ), 148 + ), 149 + backgroundColor: isDarkMode ? AppColors.deepPurple : AppColors.background, 150 + ), 151 + child: Center( 152 + child: Column( 153 + mainAxisAlignment: MainAxisAlignment.center, 154 + children: [ 155 + Text( 156 + 'Error loading profile', 157 + style: TextStyle( 158 + color: AppTheme.getTextColor(context), 159 + fontSize: 18, 160 + fontWeight: FontWeight.bold, 161 + ), 162 + ), 163 + const SizedBox(height: 8), 164 + Text( 165 + profileService.error!, 166 + style: TextStyle( 167 + color: AppTheme.getSecondaryTextColor(context), 168 + fontSize: 14, 169 + ), 170 + textAlign: TextAlign.center, 171 + ), 172 + const SizedBox(height: 16), 173 + CupertinoButton( 174 + onPressed: _loadProfile, 175 + child: const Text('Retry'), 176 + ), 177 + ], 178 + ), 179 + ), 180 + ); 181 + } 182 + 183 + // If profile data is null but no error, show a message 184 + if (profileData == null) { 185 + return CupertinoPageScaffold( 186 + backgroundColor: AppTheme.getBackgroundColor(context, false), 187 + navigationBar: CupertinoNavigationBar( 188 + middle: const Text( 189 + 'Profile', 190 + style: TextStyle( 191 + fontWeight: FontWeight.bold, 192 + fontSize: 18, 193 + ), 194 + ), 195 + backgroundColor: isDarkMode ? AppColors.deepPurple : AppColors.background, 196 + ), 197 + child: Center( 198 + child: Column( 199 + mainAxisAlignment: MainAxisAlignment.center, 200 + children: [ 201 + Text( 202 + 'Profile not found', 203 + style: TextStyle( 204 + color: AppTheme.getTextColor(context), 205 + fontSize: 18, 206 + fontWeight: FontWeight.bold, 207 + ), 208 + ), 209 + const SizedBox(height: 16), 210 + CupertinoButton( 211 + onPressed: _loadProfile, 212 + child: const Text('Retry'), 213 + ), 214 + ], 215 + ), 216 + ), 217 + ); 218 + } 219 + 220 + // Extract profile data 221 + final displayName = profileData['displayName'] ?? ''; 222 + final handle = profileData['handle'] ?? ''; 223 + final description = profileData['description'] ?? ''; 224 + final avatar = profileData['avatar']; 225 + final isCurrentUser = _isCurrentUser(profileData); 226 + 74 227 return CupertinoPageScaffold( 75 228 backgroundColor: AppTheme.getBackgroundColor(context, false), 76 229 navigationBar: CupertinoNavigationBar( 77 - middle: const Text( 78 - 'Profile', 79 - style: TextStyle( 230 + middle: Text( 231 + isCurrentUser ? 'My Profile' : 'Profile', 232 + style: const TextStyle( 80 233 fontWeight: FontWeight.bold, 81 234 fontSize: 18, 82 235 ), ··· 112 265 ), 113 266 ), 114 267 child: Center( 115 - child: Icon( 116 - Ionicons.person_outline, 117 - size: 40, 118 - color: isDarkMode ? AppColors.textLight : AppColors.textSecondary, 119 - ), 268 + child: avatar != null && avatar.isNotEmpty 269 + ? ClipOval( 270 + child: CachedNetworkImage( 271 + imageUrl: avatar, 272 + width: 90, 273 + height: 90, 274 + fit: BoxFit.cover, 275 + placeholder: (context, url) => const CupertinoActivityIndicator(), 276 + errorWidget: (context, url, error) => Icon( 277 + Ionicons.person_outline, 278 + size: 40, 279 + color: isDarkMode ? AppColors.textLight : AppColors.textSecondary, 280 + ), 281 + ), 282 + ) 283 + : Icon( 284 + Ionicons.person_outline, 285 + size: 40, 286 + color: isDarkMode ? AppColors.textLight : AppColors.textSecondary, 287 + ), 120 288 ), 121 289 ), 122 - Positioned( 123 - right: 0, 124 - bottom: 0, 125 - child: Container( 126 - width: 30, 127 - height: 30, 128 - decoration: BoxDecoration( 129 - shape: BoxShape.circle, 130 - color: AppColors.primary, 131 - border: Border.all( 132 - color: isDarkMode ? AppColors.deepPurple : AppColors.white, 133 - width: 2, 290 + if (isCurrentUser) 291 + Positioned( 292 + right: 0, 293 + bottom: 0, 294 + child: Container( 295 + width: 30, 296 + height: 30, 297 + decoration: BoxDecoration( 298 + shape: BoxShape.circle, 299 + color: AppColors.primary, 300 + border: Border.all( 301 + color: isDarkMode ? AppColors.deepPurple : AppColors.white, 302 + width: 2, 303 + ), 134 304 ), 135 - ), 136 - child: const Center( 137 - child: Icon( 138 - CupertinoIcons.plus, 139 - size: 18, 140 - color: AppColors.white, 305 + child: const Center( 306 + child: Icon( 307 + CupertinoIcons.plus, 308 + size: 18, 309 + color: AppColors.white, 310 + ), 141 311 ), 142 312 ), 143 313 ), 144 - ), 145 314 ], 146 315 ), 147 316 ··· 167 336 Row( 168 337 children: [ 169 338 Text( 170 - 'Joe Basser', 339 + displayName.isNotEmpty ? displayName : handle, 171 340 style: TextStyle( 172 341 fontWeight: FontWeight.bold, 173 342 fontSize: 18, ··· 198 367 199 368 // Username in the format seen in the screenshot 200 369 Text( 201 - '@joebasser.sprk.so', 370 + '@$handle', 202 371 style: TextStyle( 203 372 color: AppTheme.getSecondaryTextColor(context), 204 373 fontSize: 14, 205 374 ), 206 375 ), 207 376 208 - const SizedBox(height: 4), 209 - 210 - // Website 211 - Text( 212 - 'www.website.com', 213 - style: TextStyle( 214 - color: AppColors.blue, 215 - fontSize: 14, 377 + if (description.isNotEmpty) ...[ 378 + const SizedBox(height: 8), 379 + Text( 380 + description, 381 + style: TextStyle( 382 + color: AppTheme.getTextColor(context), 383 + fontSize: 14, 384 + ), 216 385 ), 217 - ), 386 + ], 218 387 219 388 const SizedBox(height: 16), 220 389 221 390 // Action buttons in a row 222 391 Row( 223 392 children: [ 224 - // Edit button 225 - Expanded( 226 - flex: 1, 227 - child: ProfileActionButton( 228 - label: 'Edit', 229 - onPressed: () => _checkAuthAndProceed(() { 230 - // Edit profile logic here 231 - }), 232 - isPrimary: true, 233 - isOutlined: false, 393 + // Edit button - only for current user 394 + if (isCurrentUser) ...[ 395 + Expanded( 396 + flex: 1, 397 + child: ProfileActionButton( 398 + label: 'Edit', 399 + onPressed: () => _checkAuthAndProceed(() { 400 + // Edit profile logic here 401 + }), 402 + isPrimary: true, 403 + isOutlined: false, 404 + ), 234 405 ), 235 - ), 236 - 237 - const SizedBox(width: 8), 406 + const SizedBox(width: 8), 407 + ], 238 408 239 409 // Share Profile button 240 410 Expanded( ··· 252 422 253 423 const SizedBox(width: 8), 254 424 255 - // Friends + button 425 + // Follow button for non-current user, Friends+ for current user 256 426 Expanded( 257 427 flex: 1, 258 428 child: ProfileActionButton( 259 - label: 'Friends +', 429 + label: isCurrentUser ? 'Friends +' : 'Follow', 260 430 onPressed: () => _checkAuthAndProceed(() { 261 - // Friends management logic here 431 + // Follow or friends management logic here 262 432 }), 263 433 ), 264 434 ),
+2 -5
lib/services/auth_service.dart
··· 171 171 if (_atProto == null) return null; 172 172 173 173 try { 174 - // final response = await _atProto!.repo.getProfile( 175 - // actor: _session.did, 176 - // ); 177 - return <String, dynamic>{}; 178 - // return response.data.toJson(); 174 + final response = await _atProto!.repo.getRecord(uri: AtUri.parse('at://${_session!.did}/app.bsky.actor.profile/self')); 175 + return response.data.toJson(); 179 176 } catch (e) { 180 177 _error = e.toString(); 181 178 notifyListeners();
+104
lib/services/profile_service.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + import 'package:atproto/core.dart'; 3 + import 'auth_service.dart'; 4 + 5 + class ProfileService extends ChangeNotifier { 6 + final AuthService _authService; 7 + bool _isLoading = false; 8 + String? _error; 9 + Map<String, dynamic>? _profile; 10 + 11 + // Getters 12 + bool get isLoading => _isLoading; 13 + String? get error => _error; 14 + Map<String, dynamic>? get profile => _profile; 15 + 16 + ProfileService(this._authService); 17 + 18 + // Get profile by DID 19 + Future<Map<String, dynamic>?> getProfile(String did) async { 20 + if (!_authService.isAuthenticated) { 21 + return null; 22 + } 23 + 24 + _isLoading = true; 25 + _error = null; 26 + _profile = null; 27 + notifyListeners(); 28 + 29 + try { 30 + final atProto = _authService.atproto; 31 + if (atProto == null) { 32 + return null; 33 + } 34 + 35 + final profileData = { 36 + 'did': did, 37 + 'handle': '', 38 + 'displayName': '', 39 + 'description': '', 40 + 'avatar': '', 41 + }; 42 + 43 + // Try to resolve the handle using the DID 44 + try { 45 + final handleResponse = await atProto.identity.resolveHandle( 46 + handle: did, 47 + ); 48 + if (handleResponse.data != null && handleResponse.data.did == did) { 49 + profileData['handle'] = did; 50 + } 51 + } catch (e) { 52 + // If handle lookup fails, use DID as fallback 53 + profileData['handle'] = did; 54 + } 55 + 56 + // Then get the profile record 57 + final response = await atProto.repo.getRecord( 58 + uri: AtUri.parse('at://$did/app.bsky.actor.profile/self'), 59 + ); 60 + 61 + final recordData = response.data.toJson(); 62 + 63 + if (recordData.containsKey('value')) { 64 + final value = recordData['value']; 65 + if (value is Map<String, dynamic>) { 66 + profileData['displayName'] = value['displayName'] ?? ''; 67 + profileData['description'] = value['description'] ?? ''; 68 + 69 + if (value['avatar'] != null && 70 + value['avatar']['ref'] != null && 71 + value['avatar']['ref']['\$link'] != null) { 72 + final avatarLink = value['avatar']['ref']['\$link']; 73 + profileData['avatar'] = 74 + 'https://cdn.bsky.app/img/feed_fullsize/plain/$did/$avatarLink@jpeg'; 75 + } 76 + } 77 + } 78 + 79 + _profile = profileData; 80 + _isLoading = false; 81 + notifyListeners(); 82 + return profileData; 83 + } catch (e) { 84 + _error = e.toString(); 85 + _isLoading = false; 86 + notifyListeners(); 87 + return null; 88 + } 89 + } 90 + 91 + // Get current user's profile 92 + Future<Map<String, dynamic>?> getCurrentUserProfile() async { 93 + if (!_authService.isAuthenticated || _authService.session == null) { 94 + return null; 95 + } 96 + return getProfile(_authService.session!.did); 97 + } 98 + 99 + // Clear error 100 + void clearError() { 101 + _error = null; 102 + notifyListeners(); 103 + } 104 + }