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

Use bsky follow mode as default + fix refresh on profile (#44)

authored by

Davi Rodrigues and committed by
GitHub
ad3353e4 6ca06d0f

+97 -570
-156
lib/screens/follow_mode_screen.dart
··· 1 - import 'package:flutter/material.dart'; 2 - import 'package:provider/provider.dart'; 3 - 4 - import '../services/auth_service.dart'; 5 - import '../services/onboarding_service.dart'; 6 - import '../services/settings_service.dart'; 7 - import '../utils/app_colors.dart'; 8 - import 'import_follows_screen.dart'; 9 - 10 - class FollowModeScreen extends StatefulWidget { 11 - final String displayName; 12 - final String description; 13 - final dynamic avatar; 14 - 15 - const FollowModeScreen({super.key, required this.displayName, required this.description, required this.avatar}); 16 - 17 - @override 18 - State<FollowModeScreen> createState() => _FollowModeScreenState(); 19 - } 20 - 21 - class _FollowModeScreenState extends State<FollowModeScreen> { 22 - bool _isLoading = false; 23 - 24 - Future<void> _selectSparkExclusive() async { 25 - if (!mounted) return; 26 - setState(() => _isLoading = true); 27 - 28 - final settingsService = Provider.of<SettingsService>(context, listen: false); 29 - await settingsService.setFollowMode(FollowMode.sprk); 30 - 31 - if (!mounted) return; 32 - Navigator.of(context).pushReplacement( 33 - MaterialPageRoute( 34 - builder: 35 - (context) => 36 - ImportFollowsScreen(displayName: widget.displayName, description: widget.description, avatar: widget.avatar), 37 - ), 38 - ); 39 - } 40 - 41 - Future<void> _selectBlueskySynced() async { 42 - if (!mounted) return; 43 - setState(() => _isLoading = true); 44 - 45 - final authService = Provider.of<AuthService>(context, listen: false); 46 - final settingsService = Provider.of<SettingsService>(context, listen: false); 47 - final onboardingService = OnboardingService(authService); 48 - 49 - try { 50 - await settingsService.setFollowMode(FollowMode.bsky); 51 - await onboardingService.finalizeProfileCreation( 52 - displayName: widget.displayName, 53 - description: widget.description, 54 - avatar: widget.avatar, 55 - ); 56 - 57 - if (!mounted) return; 58 - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 59 - } catch (e) { 60 - if (!mounted) return; 61 - // Make sure to stop loading on error 62 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error finishing onboarding: ${e.toString()}'))); 63 - } finally { 64 - if (mounted) { 65 - setState(() => _isLoading = false); 66 - } 67 - } 68 - } 69 - 70 - @override 71 - Widget build(BuildContext context) { 72 - final isDark = Theme.of(context).brightness == Brightness.dark; 73 - final backgroundColor = isDark ? AppColors.darkBackground : AppColors.lightBackground; 74 - final textColor = isDark ? AppColors.white : AppColors.textPrimary; 75 - 76 - return Scaffold( 77 - backgroundColor: backgroundColor, 78 - appBar: AppBar( 79 - backgroundColor: backgroundColor, 80 - elevation: 0, 81 - title: const Text('Choose Follow Mode'), 82 - centerTitle: true, 83 - automaticallyImplyLeading: true, // Show back button 84 - iconTheme: IconThemeData(color: textColor), 85 - ), 86 - body: 87 - _isLoading 88 - ? const Center(child: CircularProgressIndicator(color: Colors.white)) 89 - : Padding( 90 - padding: const EdgeInsets.all(24.0), 91 - child: Column( 92 - mainAxisAlignment: MainAxisAlignment.center, 93 - crossAxisAlignment: CrossAxisAlignment.stretch, 94 - children: [ 95 - Text( 96 - 'How do you want to manage your follows?', 97 - textAlign: TextAlign.center, 98 - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor), 99 - ), 100 - const SizedBox(height: 12), 101 - Text( 102 - 'Spark can manage its own list of follows, or sync with your Bluesky follows.', 103 - textAlign: TextAlign.center, 104 - style: TextStyle(fontSize: 14, color: textColor.withValues(alpha: 0.7)), 105 - ), 106 - const SizedBox(height: 40), 107 - ElevatedButton( 108 - onPressed: _selectSparkExclusive, 109 - style: ElevatedButton.styleFrom( 110 - backgroundColor: AppColors.pink, 111 - foregroundColor: Colors.white, 112 - padding: const EdgeInsets.symmetric(vertical: 16), 113 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 114 - elevation: 2, 115 - ), 116 - child: const Text( 117 - 'Spark Exclusive (Recommended)', 118 - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), 119 - ), 120 - ), 121 - const SizedBox(height: 8), 122 - Padding( 123 - padding: const EdgeInsets.symmetric(horizontal: 16.0), 124 - child: Text( 125 - 'Import your Bluesky follows (optional) and manage them independently in Spark.', 126 - textAlign: TextAlign.center, 127 - style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.6)), 128 - ), 129 - ), 130 - const SizedBox(height: 24), 131 - ElevatedButton( 132 - onPressed: _selectBlueskySynced, 133 - style: ElevatedButton.styleFrom( 134 - backgroundColor: AppColors.blue, 135 - foregroundColor: Colors.white, 136 - padding: const EdgeInsets.symmetric(vertical: 16), 137 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 138 - elevation: 2, 139 - ), 140 - child: const Text('Bluesky Synced', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 141 - ), 142 - const SizedBox(height: 8), 143 - Padding( 144 - padding: const EdgeInsets.symmetric(horizontal: 16.0), 145 - child: Text( 146 - 'Your follows in Spark will mirror your Bluesky follows.', 147 - textAlign: TextAlign.center, 148 - style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.6)), 149 - ), 150 - ), 151 - ], 152 - ), 153 - ), 154 - ); 155 - } 156 - }
-338
lib/screens/import_follows_screen.dart
··· 1 - import 'package:bluesky/bluesky.dart' as bs; 2 - import 'package:cached_network_image/cached_network_image.dart'; 3 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 - import 'package:flutter/material.dart'; 5 - import 'package:flutter_svg/flutter_svg.dart'; 6 - import 'package:provider/provider.dart'; 7 - 8 - import '../services/auth_service.dart'; 9 - import '../services/onboarding_service.dart'; 10 - import '../utils/app_colors.dart'; 11 - 12 - class ImportFollowsScreen extends StatefulWidget { 13 - final String displayName; 14 - final String description; 15 - final dynamic avatar; 16 - const ImportFollowsScreen({super.key, required this.displayName, required this.description, required this.avatar}); 17 - 18 - @override 19 - State<ImportFollowsScreen> createState() => _ImportFollowsScreenState(); 20 - } 21 - 22 - class _ImportFollowsScreenState extends State<ImportFollowsScreen> { 23 - bool _loading = true; 24 - bool _followingAll = false; 25 - String? _statusMessage; 26 - List<bs.Actor> _filteredFollows = []; 27 - List<bs.Actor> _allActors = []; 28 - Set<String> _followed = {}; // DIDs the user already follows in Spark 29 - bool _loadingExistingFollows = false; 30 - final TextEditingController _searchController = TextEditingController(); 31 - 32 - @override 33 - void initState() { 34 - super.initState(); 35 - _initialize(); 36 - _searchController.addListener(_onSearchChanged); 37 - } 38 - 39 - @override 40 - void dispose() { 41 - _searchController.removeListener(_onSearchChanged); 42 - _searchController.dispose(); 43 - super.dispose(); 44 - } 45 - 46 - // Initialize by loading both existing Spark follows and Bluesky follows 47 - Future<void> _initialize() async { 48 - await _loadExistingSparkFollows(); 49 - await _loadBlueskyFollows(); 50 - } 51 - 52 - // Load existing follows in Spark from PDS 53 - Future<void> _loadExistingSparkFollows() async { 54 - if (!mounted) return; 55 - 56 - setState(() { 57 - _loadingExistingFollows = true; 58 - }); 59 - 60 - try { 61 - final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 62 - final sparkFollows = await service.getCurrentSparkFollows(); 63 - 64 - if (!mounted) return; 65 - setState(() { 66 - _followed = sparkFollows; 67 - _loadingExistingFollows = false; 68 - }); 69 - } catch (e) { 70 - if (!mounted) return; 71 - setState(() { 72 - _loadingExistingFollows = false; 73 - }); 74 - } 75 - } 76 - 77 - Future<void> _loadBlueskyFollows() async { 78 - final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 79 - final follows = await service.getBskyFollows(); 80 - if (!mounted) return; 81 - 82 - // If there are no follows, skip import and finish onboarding immediately 83 - if (follows.follows.isEmpty) { 84 - await _finishOnboarding(); 85 - return; 86 - } 87 - 88 - setState(() { 89 - _allActors = List.from(follows.follows); 90 - _filteredFollows = _allActors; 91 - _loading = false; 92 - }); 93 - 94 - // Load remaining pages in background 95 - _prefetchRemainingFollows(follows.cursor); 96 - } 97 - 98 - Future<void> _prefetchRemainingFollows(String? cursor) async { 99 - if (cursor == null) return; 100 - final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 101 - String? nextCursor = cursor; 102 - while (mounted && nextCursor != null) { 103 - try { 104 - final page = await service.getBskyFollows(cursor: nextCursor); 105 - nextCursor = page.cursor; 106 - if (!mounted) break; 107 - setState(() { 108 - _allActors.addAll(page.follows); 109 - final query = _searchController.text.toLowerCase(); 110 - _filteredFollows = 111 - query.isEmpty ? _allActors : _allActors.where((actor) => actor.handle.toLowerCase().contains(query)).toList(); 112 - }); 113 - } catch (_) { 114 - break; 115 - } 116 - } 117 - } 118 - 119 - void _onSearchChanged() { 120 - final query = _searchController.text.toLowerCase(); 121 - setState(() { 122 - _filteredFollows = _allActors.where((actor) => actor.handle.toLowerCase().contains(query)).toList(); 123 - }); 124 - } 125 - 126 - Future<void> _follow(String did) async { 127 - final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 128 - await service.createSparkFollow(did); 129 - setState(() => _followed.add(did)); 130 - } 131 - 132 - Future<void> _followAll() async { 133 - if (_followingAll) return; // Prevent multiple follow all operations 134 - 135 - setState(() { 136 - _followingAll = true; 137 - _statusMessage = 'Following accounts...'; 138 - }); 139 - 140 - try { 141 - final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 142 - 143 - // Filter out already followed accounts 144 - final toFollow = _allActors.map((actor) => actor.did).where((did) => !_followed.contains(did)).toList(); 145 - 146 - if (toFollow.isEmpty) { 147 - setState(() { 148 - _followingAll = false; 149 - _statusMessage = null; 150 - }); 151 - return; 152 - } 153 - 154 - // Use batch follows to follow accounts 155 - final followed = await service.createBatchFollows(toFollow); 156 - 157 - // Update followed status for each account 158 - setState(() { 159 - _followed.addAll(followed); 160 - _followingAll = false; 161 - _statusMessage = null; 162 - }); 163 - 164 - // Show success message 165 - if (!mounted) return; 166 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Successfully followed ${followed.length} accounts'))); 167 - } catch (e) { 168 - if (!mounted) return; 169 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error following accounts: ${e.toString()}'))); 170 - } finally { 171 - if (mounted) { 172 - setState(() { 173 - _followingAll = false; 174 - _statusMessage = null; 175 - }); 176 - } 177 - } 178 - } 179 - 180 - @override 181 - Widget build(BuildContext context) { 182 - final isDark = Theme.of(context).brightness == Brightness.dark; 183 - final bgColor = isDark ? AppColors.darkBackground : AppColors.lightBackground; 184 - return Scaffold( 185 - backgroundColor: bgColor, 186 - appBar: AppBar( 187 - centerTitle: true, 188 - backgroundColor: bgColor, 189 - elevation: 0, 190 - leading: Padding( 191 - padding: const EdgeInsets.all(8.0), 192 - child: Container( 193 - decoration: BoxDecoration( 194 - color: isDark ? const Color(0xFF201D22) : AppColors.lightLavender, 195 - borderRadius: BorderRadius.circular(8), 196 - ), 197 - child: IconButton( 198 - icon: Icon(FluentIcons.ios_arrow_ltr_24_filled, color: isDark ? Colors.white : AppColors.darkPurple), 199 - onPressed: () => Navigator.of(context).pop(), 200 - tooltip: 'Back', 201 - ), 202 - ), 203 - ), 204 - title: SvgPicture.asset(isDark ? 'assets/images/bskywordmark.svg' : 'assets/images/bskywordmark_light.svg', height: 24), 205 - actions: [ 206 - TextButton( 207 - onPressed: _finishOnboarding, 208 - style: TextButton.styleFrom(foregroundColor: AppColors.pink), 209 - child: const Text('Finish'), 210 - ), 211 - const SizedBox(width: 8), 212 - ], 213 - ), 214 - body: SafeArea( 215 - child: 216 - _loading 217 - ? const Center(child: CircularProgressIndicator(color: Colors.white)) 218 - : Padding( 219 - padding: const EdgeInsets.all(16), 220 - child: Column( 221 - crossAxisAlignment: CrossAxisAlignment.stretch, 222 - children: [ 223 - const Text( 224 - 'Follow the same accounts you follow on Bluesky?', 225 - textAlign: TextAlign.center, 226 - style: TextStyle(fontSize: 16), 227 - ), 228 - const SizedBox(height: 8), 229 - if (_loadingExistingFollows) 230 - const Padding( 231 - padding: EdgeInsets.symmetric(vertical: 4), 232 - child: Center( 233 - child: Text( 234 - 'Loading your existing follows...', 235 - style: TextStyle(fontSize: 13, fontStyle: FontStyle.italic), 236 - ), 237 - ), 238 - ), 239 - const SizedBox(height: 16), 240 - TextField( 241 - controller: _searchController, 242 - decoration: InputDecoration( 243 - hintText: 'Search', 244 - prefixIcon: const Icon(Icons.search), 245 - filled: true, 246 - fillColor: isDark ? Colors.grey[800] : Colors.grey[200], 247 - enabledBorder: OutlineInputBorder( 248 - borderRadius: BorderRadius.circular(8), 249 - borderSide: BorderSide(color: AppColors.border), 250 - ), 251 - border: OutlineInputBorder( 252 - borderRadius: BorderRadius.circular(8), 253 - borderSide: BorderSide(color: AppColors.border), 254 - ), 255 - ), 256 - ), 257 - const SizedBox(height: 16), 258 - if (_statusMessage != null) 259 - Padding( 260 - padding: const EdgeInsets.only(bottom: 16), 261 - child: Row( 262 - children: [ 263 - const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), 264 - const SizedBox(width: 12), 265 - Expanded(child: Text(_statusMessage!)), 266 - ], 267 - ), 268 - ), 269 - Expanded( 270 - child: ListView.separated( 271 - addAutomaticKeepAlives: false, 272 - addRepaintBoundaries: false, 273 - itemCount: _filteredFollows.length, 274 - separatorBuilder: (_, __) => const SizedBox(height: 8), 275 - itemBuilder: (context, index) { 276 - final actor = _filteredFollows[index]; 277 - final isFollowed = _followed.contains(actor.did); 278 - return ListTile( 279 - leading: CircleAvatar(backgroundImage: CachedNetworkImageProvider(actor.avatar ?? '')), 280 - title: Text(actor.displayName ?? ''), 281 - subtitle: Text(actor.handle, style: TextStyle(color: AppColors.hintText)), 282 - trailing: OutlinedButton( 283 - onPressed: isFollowed ? null : () => _follow(actor.did), 284 - style: OutlinedButton.styleFrom( 285 - side: BorderSide(color: AppColors.pink), 286 - foregroundColor: AppColors.pink, 287 - disabledForegroundColor: AppColors.pink.withValues(alpha: 0.5), 288 - disabledBackgroundColor: AppColors.pink.withValues(alpha: 0.05), 289 - ), 290 - child: Text(isFollowed ? 'Following' : 'Follow'), 291 - ), 292 - ); 293 - }, 294 - ), 295 - ), 296 - ElevatedButton( 297 - onPressed: _followingAll ? null : _followAll, 298 - style: ElevatedButton.styleFrom( 299 - backgroundColor: AppColors.pink, 300 - disabledBackgroundColor: AppColors.pink.withValues(alpha: 0.5), 301 - ), 302 - child: Text(_followingAll ? 'Following...' : 'Follow all', style: const TextStyle(color: Colors.white)), 303 - ), 304 - ], 305 - ), 306 - ), 307 - ), 308 - ); 309 - } 310 - 311 - Future<void> _finishOnboarding() async { 312 - if (!mounted) return; 313 - setState(() => _loading = true); 314 - 315 - final authService = Provider.of<AuthService>(context, listen: false); 316 - final onboardingService = OnboardingService(authService); 317 - 318 - try { 319 - // The avatar (widget.avatar) is passed directly. 320 - // finalizeProfileCreation will handle if it's Uint8List or existing data. 321 - await onboardingService.finalizeProfileCreation( 322 - displayName: widget.displayName, 323 - description: widget.description, 324 - avatar: widget.avatar, 325 - ); 326 - 327 - if (!mounted) return; 328 - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 329 - } catch (e) { 330 - if (!mounted) return; 331 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error finishing onboarding: ${e.toString()}'))); 332 - } finally { 333 - if (mounted) { 334 - setState(() => _loading = false); 335 - } 336 - } 337 - } 338 - }
+15 -32
lib/screens/onboarding_screen.dart
··· 9 9 import '../services/settings_service.dart'; 10 10 import '../utils/app_colors.dart'; 11 11 import '../widgets/common/custom_text_field.dart'; 12 - import 'follow_mode_screen.dart'; 13 12 14 13 class OnboardingScreen extends StatefulWidget { 15 14 const OnboardingScreen({super.key}); ··· 86 85 }); 87 86 88 87 try { 89 - if (_bskyProfile == null) { 90 - final settingsService = Provider.of<SettingsService>(context, listen: false); 91 - await settingsService.setFollowMode(FollowMode.sprk); 92 - if (!mounted) return; 93 - final authService = Provider.of<AuthService>(context, listen: false); 94 - final onboardingService = OnboardingService(authService); 88 + final authService = Provider.of<AuthService>(context, listen: false); 89 + final settingsService = Provider.of<SettingsService>(context, listen: false); 90 + final onboardingService = OnboardingService(authService); 95 91 96 - await onboardingService.finalizeProfileCreation( 97 - displayName: _displayNameController.text.trim(), 98 - description: _descriptionController.text.trim(), 99 - avatar: _localAvatar, 100 - ); 92 + // Always default to Bluesky synced mode 93 + await settingsService.setFollowMode(FollowMode.bsky); 101 94 102 - if (!mounted) return; 103 - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 104 - } else { 105 - if (!mounted) return; 106 - Navigator.of(context).push( 107 - MaterialPageRoute( 108 - builder: 109 - (context) => FollowModeScreen( 110 - displayName: _displayNameController.text.trim(), 111 - description: _descriptionController.text.trim(), 112 - avatar: _localAvatar, 113 - ), 114 - ), 115 - ); 116 - if (mounted) { 117 - setState(() { 118 - _loading = false; 119 - }); 120 - } 121 - } 95 + if (!mounted) return; 96 + 97 + await onboardingService.finalizeProfileCreation( 98 + displayName: _displayNameController.text.trim(), 99 + description: _descriptionController.text.trim(), 100 + avatar: _localAvatar, 101 + ); 102 + 103 + if (!mounted) return; 104 + Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 122 105 } catch (e) { 123 106 if (!mounted) return; 124 107 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error processing profile: ${e.toString()}')));
+12 -27
lib/screens/profile_screen.dart
··· 39 39 String? _error; 40 40 Profile? _profile; 41 41 bool _isEarlySupporter = false; 42 + int _refreshKey = 0; 42 43 43 - // Keep this screen in memory when navigating 44 44 @override 45 45 bool get wantKeepAlive => true; 46 46 ··· 88 88 89 89 try { 90 90 final profileService = Provider.of<ProfileService>(context, listen: false); 91 - 92 - // Load profile data first 93 91 final profile = await profileService.getProfile(targetDid); 94 92 95 93 if (!mounted) return; ··· 99 97 _isLoading = false; 100 98 }); 101 99 102 - // Check early supporter status independently 103 100 _checkEarlySupporter(targetDid) 104 101 .then((isSupporter) { 105 102 if (mounted) { ··· 110 107 }) 111 108 .catchError((e) { 112 109 debugPrint('Error checking early supporter status: $e'); 113 - // Keep default value (false) on error 114 110 }); 115 111 } catch (e) { 116 112 if (!mounted) return; ··· 119 115 _error = e.toString(); 120 116 _isLoading = false; 121 117 }); 122 - debugPrint('Unexpected error in _loadProfile: $e'); 123 118 } 124 119 } 125 120 ··· 172 167 } 173 168 } 174 169 175 - /// Navigate to EditProfileScreen and refresh profile on update 176 170 void _navigateToEdit() { 177 171 if (_profile == null) return; 178 172 Navigator.of(context).push<bool>(MaterialPageRoute(builder: (context) => EditProfileScreen(profile: _profile!))).then(( ··· 201 195 202 196 if (!mounted) return; 203 197 204 - // Update the profile data with new follow status 198 + final newFollowersCount = _profile!.followersCount + (newFollowUri != null ? 1 : -1); 199 + 205 200 setState(() { 206 201 _profile = Profile( 207 202 username: _profile!.username, ··· 210 205 description: _profile!.description, 211 206 avatarUrl: _profile!.avatarUrl, 212 207 bannerUrl: _profile!.bannerUrl, 213 - followersCount: _profile!.followersCount + (newFollowUri != null ? 1 : -1), 208 + followersCount: newFollowersCount, 214 209 followingCount: _profile!.followingCount, 215 210 postsCount: _profile!.postsCount, 216 211 isSprk: _profile!.isSprk, ··· 219 214 ); 220 215 }); 221 216 222 - // Show success message 223 - ScaffoldMessenger.of(context).showSnackBar( 224 - SnackBar( 225 - content: Text(newFollowUri != null ? 'Followed successfully' : 'Unfollowed successfully'), 226 - backgroundColor: Colors.green, 227 - ), 228 - ); 217 + final message = newFollowUri != null ? 'Followed successfully' : 'Unfollowed successfully'; 218 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message), backgroundColor: Colors.green)); 229 219 } catch (e) { 230 220 if (!mounted) return; 231 221 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'), backgroundColor: Colors.red)); ··· 243 233 builder: 244 234 (context) => ReportDialog( 245 235 postUri: 'at://$did/app.bsky.actor.profile/self', 246 - postCid: 'profile', // Using placeholder, the DID is the important part 236 + postCid: 'profile', 247 237 onSubmit: (subject, reasonType, reason, service) async { 248 238 try { 249 - // Create report for a profile 250 239 final result = await modService.createReport( 251 240 subject: ReportSubject.repoRef(data: RepoRef(did: did)), 252 241 reasonType: reasonType, ··· 267 256 ); 268 257 } 269 258 270 - /// Refreshes the profile by forcing a refetch from the server and clearing cache 271 259 Future<void> _refreshProfile() async { 272 260 if (!mounted) return; 273 261 274 262 final targetDid = widget.did ?? Provider.of<AuthService>(context, listen: false).session?.did; 263 + 275 264 if (targetDid == null) return; 276 265 277 266 try { 278 267 final profileService = Provider.of<ProfileService>(context, listen: false); 279 268 280 - await profileService.clearProfileCache(targetDid); 281 - 269 + await profileService.clearAllCache(targetDid); 282 270 final profile = await profileService.getProfile(targetDid, forceRefresh: true); 283 271 284 272 if (!mounted) return; 285 273 286 274 setState(() { 287 275 _profile = profile; 276 + _refreshKey++; 288 277 }); 289 278 290 279 final isSupporter = await _checkEarlySupporter(targetDid); ··· 307 296 308 297 @override 309 298 Widget build(BuildContext context) { 310 - super.build(context); // Required for AutomaticKeepAliveClientMixin 299 + super.build(context); 311 300 312 301 final brightness = MediaQuery.of(context).platformBrightness; 313 302 final isDarkMode = brightness == Brightness.dark; ··· 346 335 isAuthenticated: isAuthenticated, 347 336 onLoginPressed: _handleLogin, 348 337 did: _profile!.did, 338 + refreshKey: _refreshKey, 349 339 ); 350 340 351 341 return Scaffold( ··· 387 377 child: CustomScrollView( 388 378 key: PageStorageKey<String>('profile_${widget.did ?? 'current'}'), 389 379 slivers: [ 390 - // Profile header 391 380 SliverToBoxAdapter( 392 381 child: ProfileHeader( 393 382 profile: _profile!, ··· 400 389 onSettingsTap: _handleSettingsTap, 401 390 ), 402 391 ), 403 - 404 - // Tab bar (pinned) 405 392 SliverPersistentHeader( 406 393 pinned: true, 407 394 delegate: StickyTabBarDelegate( ··· 412 399 ), 413 400 ), 414 401 ), 415 - 416 - // Dynamic tab content 417 402 ...tabContent.getTabContent(), 418 403 ], 419 404 ),
+56 -10
lib/services/profile_service.dart
··· 21 21 22 22 String _getSprkCacheKey(String did) => 'sprk_profile_$did'; 23 23 24 + String _getBskyFeedCacheKey(String did) => 'bsky_feed_$did'; 25 + 26 + String _getSprkFeedCacheKey(String did) => 'sprk_feed_$did'; 27 + 24 28 Future<Profile?> getProfile(String did, {bool forceRefresh = false}) async { 25 29 if (!_authService.isAuthenticated) return null; 26 30 ··· 176 180 await _cacheManager.removeFile(_getSprkCacheKey(did)); 177 181 } 178 182 179 - Future<Map<String, dynamic>?> getProfileVideosSprk(String did) async { 183 + // Clear all feed caches for a specific DID 184 + Future<void> clearFeedCache(String did) async { 185 + await _cacheManager.removeFile(_getBskyFeedCacheKey(did)); 186 + await _cacheManager.removeFile(_getSprkFeedCacheKey(did)); 187 + } 188 + 189 + // Clear all caches (profile + feed) for a specific DID 190 + Future<void> clearAllCache(String did) async { 191 + await clearProfileCache(did); 192 + await clearFeedCache(did); 193 + } 194 + 195 + Future<Map<String, dynamic>?> getProfileVideosSprk(String did, {bool forceRefresh = false}) async { 180 196 if (!_authService.isAuthenticated) { 181 197 return null; 182 198 } 183 199 200 + final cacheKey = _getSprkFeedCacheKey(did); 201 + 202 + if (!forceRefresh) { 203 + try { 204 + final cacheFile = await _cacheManager.getFileFromCache(cacheKey); 205 + if (cacheFile != null) { 206 + final jsonString = await cacheFile.file.readAsString(); 207 + return json.decode(jsonString); 208 + } 209 + } catch (e) { 210 + debugPrint('Error reading Sprk feed from cache: $e'); 211 + } 212 + } 213 + 184 214 try { 185 - // Check for existing follow first 186 215 String? existingFollowUri; 187 216 try { 188 217 final existingFollows = await _authService.atproto!.repo.listRecords( ··· 190 219 collection: NSID.parse('so.sprk.graph.follow'), 191 220 ); 192 221 193 - // Find the follow record for this DID if it exists 194 222 for (final record in existingFollows.data.records) { 195 223 if (record.value['subject'] == did) { 196 224 existingFollowUri = record.uri.toString(); ··· 198 226 } 199 227 } 200 228 } catch (e) { 201 - debugPrint('Error checking existing follows: $e'); 229 + debugPrint('Error checking existing follows for Sprk feed: $e'); 202 230 } 203 231 204 232 final client = SprkClient(_authService); ··· 209 237 } 210 238 211 239 final data = response.data; 212 - 213 240 data['viewer'] = {'following': existingFollowUri}; 214 241 242 + final jsonString = json.encode(data); 243 + await _cacheManager.putFile(cacheKey, Uint8List.fromList(utf8.encode(jsonString)), key: cacheKey, maxAge: cacheDuration); 244 + 215 245 return data; 216 246 } catch (e) { 217 247 throw Exception('Failed to fetch profile videos: $e'); 218 248 } 219 249 } 220 250 221 - Future<Map<String, dynamic>?> getProfileVideosBsky(String did) async { 251 + Future<Map<String, dynamic>?> getProfileVideosBsky(String did, {bool forceRefresh = false}) async { 222 252 if (!_authService.isAuthenticated) { 223 253 return null; 224 254 } 225 255 256 + final cacheKey = _getBskyFeedCacheKey(did); 257 + 258 + if (!forceRefresh) { 259 + try { 260 + final cacheFile = await _cacheManager.getFileFromCache(cacheKey); 261 + if (cacheFile != null) { 262 + final jsonString = await cacheFile.file.readAsString(); 263 + return json.decode(jsonString); 264 + } 265 + } catch (e) { 266 + debugPrint('Error reading Bsky feed from cache: $e'); 267 + } 268 + } 269 + 226 270 try { 227 - // Check for existing follow first 228 271 String? existingFollowUri; 229 272 try { 230 273 final existingFollows = await _authService.atproto!.repo.listRecords( ··· 232 275 collection: NSID.parse('so.sprk.graph.follow'), 233 276 ); 234 277 235 - // Find the follow record for this DID if it exists 236 278 for (final record in existingFollows.data.records) { 237 279 if (record.value['subject'] == did) { 238 280 existingFollowUri = record.uri.toString(); ··· 240 282 } 241 283 } 242 284 } catch (e) { 243 - debugPrint('Error checking existing follows: $e'); 285 + debugPrint('Error checking existing follows for Bsky feed: $e'); 244 286 } 245 287 246 288 final bsky = Bluesky.fromSession(_authService.session!); 247 289 final response = await bsky.feed.getAuthorFeed(actor: did, filter: FeedFilter.postsWithVideo); 290 + 248 291 final feed = response.data.toJson(); 249 - // Add follow status to the response 250 292 feed['viewer'] = {'following': existingFollowUri}; 293 + 294 + final jsonString = json.encode(feed); 295 + await _cacheManager.putFile(cacheKey, Uint8List.fromList(utf8.encode(jsonString)), key: cacheKey, maxAge: cacheDuration); 296 + 251 297 return feed; 252 298 } catch (e) { 253 299 throw Exception('Failed to fetch profile: $e');
+10 -3
lib/widgets/profile/profile_tab_content.dart
··· 11 11 final bool isAuthenticated; 12 12 final VoidCallback onLoginPressed; 13 13 final String? did; 14 + final int refreshKey; 14 15 15 - const ProfileTabContent({required this.selectedIndex, required this.isAuthenticated, required this.onLoginPressed, this.did}); 16 + const ProfileTabContent({ 17 + required this.selectedIndex, 18 + required this.isAuthenticated, 19 + required this.onLoginPressed, 20 + this.did, 21 + this.refreshKey = 0, 22 + }); 16 23 17 24 List<Widget> getTabContent() { 18 25 // Early return for auth check ··· 36 43 Widget _buildSelectedTabContent() { 37 44 switch (selectedIndex) { 38 45 case 0: 39 - return VideosTab(did: did); 46 + return VideosTab(key: ValueKey('videos_$refreshKey'), did: did); 40 47 case 1: 41 - return PhotosTab(did: did); 48 + return PhotosTab(key: ValueKey('photos_$refreshKey'), did: did); 42 49 case 2: 43 50 return ContentGridTab(icon: FluentIcons.heart_24_regular, type: 'favorites', itemCount: 30); 44 51 case 3:
+4 -4
lib/widgets/profile/tabs/videos_tab.dart
··· 65 65 _cursor = null; 66 66 }); 67 67 68 - await _fetchPosts(); 68 + await _fetchPosts(forceRefresh: true); 69 69 } 70 70 71 71 Future<void> _loadMorePosts() async { ··· 78 78 await _fetchPosts(isLoadingMore: true); 79 79 } 80 80 81 - Future<void> _fetchPosts({bool isLoadingMore = false}) async { 81 + Future<void> _fetchPosts({bool isLoadingMore = false, bool forceRefresh = false}) async { 82 82 if (!mounted) return; 83 83 84 84 try { ··· 96 96 return; 97 97 } 98 98 99 - final resultBsky = await profileService.getProfileVideosBsky(targetDid); 100 - final resultSprk = isLoadingMore ? null : await profileService.getProfileVideosSprk(targetDid); 99 + final resultBsky = await profileService.getProfileVideosBsky(targetDid, forceRefresh: forceRefresh); 100 + final resultSprk = isLoadingMore ? null : await profileService.getProfileVideosSprk(targetDid, forceRefresh: forceRefresh); 101 101 102 102 if (!mounted) return; 103 103