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

Add bluesky follow lexicon options and etc (#40)

authored by

Davi Rodrigues and committed by
GitHub
6585d646 c08dc861

+1037 -365
+11 -6
lib/main.dart
··· 18 18 import 'services/auth_service.dart'; 19 19 import 'services/comments_service.dart'; 20 20 import 'services/identity_service.dart'; 21 + import 'services/labeler_manager.dart'; 21 22 import 'services/profile_service.dart'; 22 23 import 'services/settings_service.dart'; 23 24 import 'services/upload_service.dart'; ··· 25 26 import 'utils/app_colors.dart'; 26 27 import 'utils/app_theme.dart'; 27 28 import 'widgets/upload/upload_progress_indicator.dart'; 28 - import 'services/labeler_manager.dart'; 29 29 30 30 // Global RouteObserver instance 31 31 final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>(); ··· 62 62 return MultiProvider( 63 63 providers: [ 64 64 ChangeNotifierProvider(create: (_) => NavigationProvider()), 65 - ChangeNotifierProvider(create: (_) => AuthService()), 65 + ChangeNotifierProvider<AuthService>(create: (_) => AuthService()), 66 66 ChangeNotifierProvider(create: (_) => CachedIdentityService()), 67 - ChangeNotifierProvider(create: (_) => SettingsService()), 67 + ChangeNotifierProxyProvider<AuthService, SettingsService>( 68 + create: (context) => SettingsService(authService: Provider.of<AuthService>(context, listen: false)), 69 + update: (context, auth, previous) => previous ?? SettingsService(authService: auth), 70 + ), 68 71 ChangeNotifierProvider(create: (_) => UploadService()), 69 72 ChangeNotifierProxyProvider<AuthService, ProfileService>( 70 73 create: (context) => ProfileService(context.read<AuthService>()), 71 74 update: (_, authService, previousProfileService) => previousProfileService ?? ProfileService(authService), 72 75 ), 73 - ChangeNotifierProxyProvider<AuthService, ActionsService>( 74 - create: (context) => ActionsService(context.read<AuthService>()), 75 - update: (_, authService, previousActionsService) => previousActionsService ?? ActionsService(authService), 76 + ChangeNotifierProxyProvider2<AuthService, SettingsService, ActionsService>( 77 + create: (context) => ActionsService(context.read<AuthService>(), context.read<SettingsService>()), 78 + update: 79 + (_, authService, settingsService, previousActionsService) => 80 + previousActionsService ?? ActionsService(authService, settingsService), 76 81 ), 77 82 ChangeNotifierProxyProvider2<AuthService, ProfileService, CommentsService>( 78 83 create: (context) => CommentsService(context.read<AuthService>(), context.read<ProfileService>()),
+4 -4
lib/models/profile.dart
··· 39 39 description: actor.description, 40 40 avatarUrl: actor.avatar, 41 41 bannerUrl: null, // Bluesky doesn't have banner 42 - followersCount: 0, // These will be set from profile.data 43 - followingCount: 0, 44 - postsCount: 0, 42 + followersCount: actor.followersCount, 43 + followingCount: actor.followsCount, 44 + postsCount: actor.postsCount, 45 45 isSprk: false, 46 46 isFollowing: actor.viewer.following != null, 47 47 followUri: actor.viewer.following?.toString(), ··· 61 61 avatarUrl: actor['avatar'] as String?, 62 62 bannerUrl: actor['banner'] as String?, 63 63 followersCount: actor['followersCount'] as int? ?? 0, 64 - followingCount: actor['followingCount'] as int? ?? 0, 64 + followingCount: actor['followsCount'] as int? ?? 0, 65 65 postsCount: actor['postsCount'] as int? ?? 0, 66 66 isSprk: true, 67 67 isFollowing: viewer?['following'] != null,
+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 + }
+197 -91
lib/screens/import_follows_screen.dart
··· 1 - import 'dart:typed_data'; 2 - 3 1 import 'package:bluesky/bluesky.dart' as bs; 4 2 import 'package:cached_network_image/cached_network_image.dart'; 5 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; ··· 9 7 10 8 import '../services/auth_service.dart'; 11 9 import '../services/onboarding_service.dart'; 12 - import '../services/sprk_client.dart'; 13 10 import '../utils/app_colors.dart'; 14 11 15 12 class ImportFollowsScreen extends StatefulWidget { ··· 24 21 25 22 class _ImportFollowsScreenState extends State<ImportFollowsScreen> { 26 23 bool _loading = true; 24 + bool _followingAll = false; 25 + String? _statusMessage; 27 26 List<bs.Actor> _filteredFollows = []; 28 27 List<bs.Actor> _allActors = []; 29 - final Set<String> _followed = {}; 28 + Set<String> _followed = {}; // DIDs the user already follows in Spark 29 + bool _loadingExistingFollows = false; 30 30 final TextEditingController _searchController = TextEditingController(); 31 31 32 32 @override 33 33 void initState() { 34 34 super.initState(); 35 - _loadFollows(); 35 + _initialize(); 36 36 _searchController.addListener(_onSearchChanged); 37 37 } 38 38 ··· 43 43 super.dispose(); 44 44 } 45 45 46 - Future<void> _loadFollows() async { 47 - final service = OnboardingService(Provider.of(context, listen: false)); 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)); 48 79 final follows = await service.getBskyFollows(); 49 80 if (!mounted) return; 81 + 50 82 // If there are no follows, skip import and finish onboarding immediately 51 83 if (follows.follows.isEmpty) { 52 84 await _finishOnboarding(); 53 85 return; 54 86 } 87 + 55 88 setState(() { 56 89 _allActors = List.from(follows.follows); 57 90 _filteredFollows = _allActors; 58 91 _loading = false; 59 92 }); 93 + 60 94 // Load remaining pages in background 61 95 _prefetchRemainingFollows(follows.cursor); 62 96 } 63 97 64 98 Future<void> _prefetchRemainingFollows(String? cursor) async { 65 99 if (cursor == null) return; 66 - final service = OnboardingService(Provider.of(context, listen: false)); 100 + final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 67 101 String? nextCursor = cursor; 68 102 while (mounted && nextCursor != null) { 69 103 try { ··· 90 124 } 91 125 92 126 Future<void> _follow(String did) async { 93 - final service = OnboardingService(Provider.of(context, listen: false)); 127 + final service = OnboardingService(Provider.of<AuthService>(context, listen: false)); 94 128 await service.createSparkFollow(did); 95 129 setState(() => _followed.add(did)); 96 130 } 97 131 98 132 Future<void> _followAll() async { 99 - final service = OnboardingService(Provider.of(context, listen: false)); 100 - for (var actor in _allActors) { 101 - if (_followed.contains(actor.did)) continue; 102 - await service.createSparkFollow(actor.did); 103 - _followed.add(actor.did); 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 + } 104 177 } 105 - if (!mounted) return; 106 - setState(() {}); 107 178 } 108 179 109 180 @override ··· 140 211 const SizedBox(width: 8), 141 212 ], 142 213 ), 143 - body: 144 - _loading 145 - ? const Center(child: CircularProgressIndicator(color: Colors.white)) 146 - : Padding( 147 - padding: const EdgeInsets.all(16), 148 - child: Column( 149 - crossAxisAlignment: CrossAxisAlignment.stretch, 150 - children: [ 151 - const Text( 152 - 'Follow the same accounts you follow on Bluesky?', 153 - textAlign: TextAlign.center, 154 - style: TextStyle(fontSize: 16), 155 - ), 156 - const SizedBox(height: 16), 157 - TextField( 158 - controller: _searchController, 159 - decoration: InputDecoration( 160 - hintText: 'Search', 161 - prefixIcon: const Icon(Icons.search), 162 - filled: true, 163 - fillColor: isDark ? Colors.grey[800] : Colors.grey[200], 164 - enabledBorder: OutlineInputBorder( 165 - borderRadius: BorderRadius.circular(8), 166 - borderSide: BorderSide(color: AppColors.border), 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 + ), 167 238 ), 168 - border: OutlineInputBorder( 169 - borderRadius: BorderRadius.circular(8), 170 - borderSide: BorderSide(color: AppColors.border), 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 + ), 171 255 ), 172 256 ), 173 - ), 174 - const SizedBox(height: 16), 175 - Expanded( 176 - child: ListView.separated( 177 - addAutomaticKeepAlives: false, 178 - addRepaintBoundaries: false, 179 - itemCount: _filteredFollows.length, 180 - separatorBuilder: (_, __) => const SizedBox(height: 8), 181 - itemBuilder: (context, index) { 182 - final did = _filteredFollows[index]; 183 - final isFollowed = _followed.contains(did.did); 184 - return ListTile( 185 - leading: CircleAvatar(backgroundImage: CachedNetworkImageProvider(did.avatar ?? '')), 186 - title: Text(did.displayName ?? ''), 187 - subtitle: Text(did.handle, style: TextStyle(color: AppColors.hintText)), 188 - trailing: OutlinedButton( 189 - onPressed: isFollowed ? null : () => _follow(did.did), 190 - style: OutlinedButton.styleFrom( 191 - side: BorderSide(color: AppColors.pink), 192 - foregroundColor: AppColors.pink, 193 - disabledForegroundColor: AppColors.pink.withValues(alpha: 0.5), 194 - disabledBackgroundColor: AppColors.pink.withValues(alpha: 0.05), 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'), 195 291 ), 196 - child: Text(isFollowed ? 'Following' : 'Follow'), 197 - ), 198 - ); 199 - }, 292 + ); 293 + }, 294 + ), 200 295 ), 201 - ), 202 - ElevatedButton( 203 - onPressed: _followAll, 204 - style: ElevatedButton.styleFrom(backgroundColor: AppColors.pink), 205 - child: const Text('Follow all', style: TextStyle(color: Colors.white)), 206 - ), 207 - ], 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 + ), 208 306 ), 209 - ), 307 + ), 210 308 ); 211 309 } 212 310 213 311 Future<void> _finishOnboarding() async { 312 + if (!mounted) return; 214 313 setState(() => _loading = true); 314 + 215 315 final authService = Provider.of<AuthService>(context, listen: false); 216 - dynamic avatarToSend = widget.avatar; 217 - if (widget.avatar is Uint8List) { 218 - final sprkClient = SprkClient(authService); 219 - final resp = await sprkClient.repo.uploadBlob(widget.avatar as Uint8List); 220 - if (resp.status.code != 200) throw Exception('Failed to upload avatar blob'); 221 - avatarToSend = resp.data.blob.toJson(); 222 - } 223 316 final onboardingService = OnboardingService(authService); 224 - await onboardingService.importCustomProfile( 225 - displayName: widget.displayName, 226 - description: widget.description, 227 - avatar: avatarToSend, 228 - ); 229 - if (!mounted) return; 230 - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 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 + } 231 337 } 232 338 }
+11
lib/screens/login_screen.dart
··· 6 6 7 7 import '../services/auth_service.dart'; 8 8 import '../services/onboarding_service.dart'; 9 + import '../services/settings_service.dart'; 9 10 import '../utils/app_colors.dart'; 10 11 import '../utils/app_theme.dart'; 11 12 import '../widgets/ataccount_dialog.dart'; ··· 68 69 69 70 if (result == LoginStatus.success) { 70 71 TextInput.finishAutofillContext(shouldSave: true); 72 + 73 + final settingsService = Provider.of<SettingsService>(context, listen: false); 74 + await settingsService.syncFollowModeFromServer(); 75 + 71 76 final onboardingService = OnboardingService(authService); 72 77 final hasSpark = await onboardingService.hasSparkProfile(); 78 + 79 + if (hasSpark) { 80 + // Fix duplicate follows if any exist, might remove this in the future 81 + await onboardingService.gambiarraFixDuplicates(); 82 + } 83 + 73 84 if (!mounted) return; 74 85 Navigator.of(context).pushNamedAndRemoveUntil(hasSpark ? '/home' : '/onboarding', (Route<dynamic> route) => false); 75 86 } else if (result == LoginStatus.codeRequired) {
+56 -12
lib/screens/onboarding_screen.dart
··· 6 6 7 7 import '../services/auth_service.dart'; 8 8 import '../services/onboarding_service.dart'; 9 + import '../services/settings_service.dart'; 9 10 import '../utils/app_colors.dart'; 10 11 import '../widgets/common/custom_text_field.dart'; 11 - import 'import_follows_screen.dart'; 12 + import 'follow_mode_screen.dart'; 12 13 13 14 class OnboardingScreen extends StatefulWidget { 14 15 const OnboardingScreen({super.key}); ··· 71 72 72 73 Future<void> _handleCustomImport() async { 73 74 if (!_formKey.currentState!.validate()) return; 74 - Navigator.of(context).push( 75 - MaterialPageRoute( 76 - builder: 77 - (context) => ImportFollowsScreen( 78 - displayName: _displayNameController.text.trim(), 79 - description: _descriptionController.text.trim(), 80 - avatar: _localAvatar, 81 - ), 82 - ), 83 - ); 75 + 76 + setState(() { 77 + _loading = true; 78 + }); 79 + 80 + try { 81 + if (_bskyProfile == null) { 82 + final settingsService = Provider.of<SettingsService>(context, listen: false); 83 + await settingsService.setFollowMode(FollowMode.sprk); 84 + if (!mounted) return; 85 + final authService = Provider.of<AuthService>(context, listen: false); 86 + final onboardingService = OnboardingService(authService); 87 + 88 + await onboardingService.finalizeProfileCreation( 89 + displayName: _displayNameController.text.trim(), 90 + description: _descriptionController.text.trim(), 91 + avatar: _localAvatar, 92 + ); 93 + 94 + if (!mounted) return; 95 + Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); 96 + } else { 97 + if (!mounted) return; 98 + Navigator.of(context).push( 99 + MaterialPageRoute( 100 + builder: 101 + (context) => FollowModeScreen( 102 + displayName: _displayNameController.text.trim(), 103 + description: _descriptionController.text.trim(), 104 + avatar: _localAvatar, 105 + ), 106 + ), 107 + ); 108 + if (mounted) { 109 + setState(() { 110 + _loading = false; 111 + }); 112 + } 113 + } 114 + } catch (e) { 115 + if (!mounted) return; 116 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error processing profile: ${e.toString()}'))); 117 + if (mounted) { 118 + setState(() { 119 + _loading = false; 120 + }); 121 + } 122 + } 84 123 } 85 124 86 125 Future<void> _pickAvatar() async { ··· 122 161 123 162 return Scaffold( 124 163 backgroundColor: backgroundColor, 125 - appBar: AppBar(backgroundColor: backgroundColor, elevation: 0, title: const Text('Complete your profile')), 164 + appBar: AppBar( 165 + backgroundColor: backgroundColor, 166 + elevation: 0, 167 + title: const Text('Complete your profile'), 168 + centerTitle: true, 169 + ), 126 170 body: Center( 127 171 child: Padding( 128 172 padding: const EdgeInsets.all(16),
+5 -20
lib/screens/profile_screen.dart
··· 17 17 import '../utils/app_theme.dart'; 18 18 import '../widgets/profile/early_supporter_sheet.dart'; 19 19 import '../widgets/profile/profile_header.dart'; 20 - import '../widgets/profile/profile_menu_sheet.dart'; 21 20 import '../widgets/profile/profile_tab_content.dart'; 22 21 import '../widgets/profile/profile_tabs.dart'; 23 22 import 'auth_prompt_screen.dart'; 24 23 import 'edit_profile_screen.dart'; 24 + import 'profile_settings_screen.dart'; 25 25 26 26 class ProfileScreen extends StatefulWidget { 27 27 final String? did; ··· 145 145 backgroundColor: Colors.transparent, 146 146 builder: (context) => SafeArea(child: Padding(padding: const EdgeInsets.only(top: 20), child: EarlySupporterSheet())), 147 147 ); 148 - } 149 - 150 - void _showProfileMenu(BuildContext context) { 151 - showModalBottomSheet( 152 - context: context, 153 - isScrollControlled: true, 154 - backgroundColor: Colors.transparent, 155 - builder: 156 - (context) => 157 - SafeArea(child: Padding(padding: const EdgeInsets.only(top: 20), child: ProfileMenuSheet(onLogout: _handleLogout))), 158 - ); 159 - } 160 - 161 - void _handleLogout() { 162 - final authService = Provider.of<AuthService>(context, listen: false); 163 - authService.logout(); 164 - debugPrint('User logged out'); 165 148 } 166 149 167 150 void _handleSettingsTap() { ··· 381 364 padding: const EdgeInsets.only(right: 8.0), 382 365 child: IconButton( 383 366 padding: EdgeInsets.zero, 384 - onPressed: () => _showProfileMenu(context), 385 - icon: Icon(FluentIcons.more_horizontal_24_regular, color: AppTheme.getTextColor(context)), 367 + onPressed: () { 368 + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ProfileSettingsScreen())); 369 + }, 370 + icon: Icon(FluentIcons.options_24_regular, color: AppTheme.getTextColor(context)), 386 371 ), 387 372 ) 388 373 else
+191
lib/screens/profile_settings_screen.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:provider/provider.dart'; 4 + 5 + import '../services/auth_service.dart'; 6 + import '../services/settings_service.dart'; 7 + import '../utils/app_colors.dart'; 8 + 9 + class ProfileSettingsScreen extends StatefulWidget { 10 + const ProfileSettingsScreen({super.key}); 11 + 12 + @override 13 + State<ProfileSettingsScreen> createState() => _ProfileSettingsScreenState(); 14 + } 15 + 16 + class _ProfileSettingsScreenState extends State<ProfileSettingsScreen> { 17 + final Map<String, FollowMode> _followModeMap = {'Spark exclusive': FollowMode.sprk, 'Bluesky synced': FollowMode.bsky}; 18 + 19 + void _handleLogout() { 20 + final authService = Provider.of<AuthService>(context, listen: false); 21 + authService.logout(); 22 + Navigator.of(context).pushNamedAndRemoveUntil('/auth', (route) => false); 23 + } 24 + 25 + @override 26 + Widget build(BuildContext context) { 27 + final brightness = Theme.of(context).brightness; 28 + final isDark = brightness == Brightness.dark; 29 + final backgroundColor = isDark ? Colors.black : AppColors.background; 30 + final textColor = isDark ? AppColors.white : AppColors.textPrimary; 31 + final itemColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200; 32 + final settingsService = Provider.of<SettingsService>(context); 33 + 34 + // Make sure we have adequate padding for the notch/dynamic island 35 + final topPadding = MediaQuery.of(context).padding.top + 24.0; 36 + 37 + return Scaffold( 38 + backgroundColor: backgroundColor, 39 + body: Material( 40 + color: backgroundColor, 41 + child: SizedBox( 42 + height: MediaQuery.of(context).size.height, 43 + width: MediaQuery.of(context).size.width, 44 + child: Column( 45 + children: [ 46 + // Add extra padding at the top for the notch/camera hole 47 + SizedBox(height: topPadding), 48 + _buildHeader(context, textColor), 49 + 50 + // Main content 51 + Expanded( 52 + child: ListView( 53 + padding: const EdgeInsets.symmetric(horizontal: 16), 54 + children: [ 55 + _buildFollowModeItem( 56 + isDark: isDark, 57 + itemColor: itemColor, 58 + textColor: textColor, 59 + settingsService: settingsService, 60 + ), 61 + 62 + const SizedBox(height: 16), 63 + 64 + // Logout button 65 + Padding( 66 + padding: const EdgeInsets.symmetric(vertical: 8), 67 + child: Material( 68 + color: Colors.transparent, 69 + child: Container( 70 + decoration: BoxDecoration( 71 + color: Colors.red.withValues(alpha: 0.1), 72 + borderRadius: BorderRadius.circular(12), 73 + ), 74 + child: ListTile( 75 + title: Text('Logout', style: TextStyle(color: Colors.red, fontSize: 16, fontWeight: FontWeight.bold)), 76 + trailing: Icon(FluentIcons.sign_out_24_regular, color: Colors.red), 77 + onTap: _handleLogout, 78 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 79 + ), 80 + ), 81 + ), 82 + ), 83 + ], 84 + ), 85 + ), 86 + 87 + // Bottom safe area 88 + SizedBox(height: MediaQuery.of(context).padding.bottom), 89 + ], 90 + ), 91 + ), 92 + ), 93 + ); 94 + } 95 + 96 + Widget _buildHeader(BuildContext context, Color textColor) { 97 + return Padding( 98 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), 99 + child: Row( 100 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 101 + children: [ 102 + IconButton( 103 + icon: Icon(FluentIcons.arrow_left_24_regular, color: textColor), 104 + onPressed: () => Navigator.of(context).pop(), 105 + ), 106 + Text('Profile Settings', style: TextStyle(color: textColor, fontSize: 18, fontWeight: FontWeight.bold)), 107 + const SizedBox(width: 48), // For balance 108 + ], 109 + ), 110 + ); 111 + } 112 + 113 + Widget _buildFollowModeItem({ 114 + required bool isDark, 115 + required Color itemColor, 116 + required Color textColor, 117 + required SettingsService settingsService, 118 + }) { 119 + final currentMode = settingsService.followMode; 120 + final displayValues = _followModeMap.keys.toList(); // ['Spark exclusive', 'Bluesky synced'] 121 + final modeValues = _followModeMap.values.toList(); // [FollowMode.sprk, FollowMode.bsky] 122 + 123 + Widget buildModeButton(String displayValue, FollowMode modeValue) { 124 + final bool isSelected = currentMode == modeValue; 125 + return Expanded( 126 + child: Padding( 127 + padding: const EdgeInsets.symmetric(horizontal: 4.0), 128 + child: ElevatedButton( 129 + onPressed: () { 130 + if (!isSelected) { 131 + settingsService.setFollowMode(modeValue); 132 + } 133 + }, 134 + style: ElevatedButton.styleFrom( 135 + backgroundColor: isSelected ? AppColors.pink : (isDark ? Colors.grey.shade700 : Colors.grey.shade300), 136 + foregroundColor: isSelected ? Colors.white : textColor, 137 + padding: const EdgeInsets.symmetric(vertical: 12), 138 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 139 + elevation: isSelected ? 2 : 0, 140 + side: 141 + isSelected 142 + ? BorderSide.none 143 + : BorderSide(color: isDark ? Colors.grey.shade600 : Colors.grey.shade400, width: 0.5), 144 + ), 145 + child: Text(displayValue, textAlign: TextAlign.center, style: const TextStyle(fontWeight: FontWeight.w600)), 146 + ), 147 + ), 148 + ); 149 + } 150 + 151 + return Padding( 152 + padding: const EdgeInsets.symmetric(vertical: 8.0), 153 + child: Container( 154 + decoration: BoxDecoration( 155 + color: itemColor, // This is the overall card background 156 + borderRadius: BorderRadius.circular(16), 157 + ), 158 + padding: const EdgeInsets.all(16), 159 + child: Column( 160 + crossAxisAlignment: CrossAxisAlignment.start, 161 + children: [ 162 + Text('Follow Mode', style: TextStyle(color: textColor, fontSize: 18, fontWeight: FontWeight.bold)), 163 + const SizedBox(height: 6), 164 + Text( 165 + 'Choose how your follows are managed across Spark.', 166 + style: TextStyle(color: textColor.withValues(alpha: 0.7), fontSize: 13), 167 + ), 168 + const SizedBox(height: 16), 169 + Row( 170 + mainAxisAlignment: MainAxisAlignment.center, 171 + children: [ 172 + buildModeButton(displayValues[0], modeValues[0]), // Spark exclusive 173 + buildModeButton(displayValues[1], modeValues[1]), // Bluesky synced 174 + ], 175 + ), 176 + const SizedBox(height: 8), 177 + Center( 178 + child: Text( 179 + currentMode == FollowMode.sprk 180 + ? 'You are managing follows within Spark only.' 181 + : 'Your follows are synced with Bluesky.', 182 + style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.6)), 183 + textAlign: TextAlign.center, 184 + ), 185 + ), 186 + ], 187 + ), 188 + ), 189 + ); 190 + } 191 + }
+6
lib/screens/register_screen.dart
··· 6 6 import '../config/app_config.dart'; 7 7 import '../services/auth_service.dart'; 8 8 import '../services/onboarding_service.dart'; 9 + import '../services/settings_service.dart'; 9 10 import '../utils/app_colors.dart'; 10 11 import '../utils/app_theme.dart'; 11 12 import '../widgets/ataccount_dialog.dart'; ··· 58 59 }); 59 60 60 61 if (success) { 62 + if (!mounted) return; 63 + final settingsService = Provider.of<SettingsService>(context, listen: false); 64 + await settingsService.syncFollowModeFromServer(); 65 + 61 66 final onboardingService = OnboardingService(authService); 62 67 final hasSpark = await onboardingService.hasSparkProfile(); 68 + 63 69 if (!mounted) return; 64 70 Navigator.of(context).pushReplacementNamed(hasSpark ? '/home' : '/onboarding'); 65 71 } else {
+2 -11
lib/screens/splash_screen.dart
··· 4 4 import 'package:provider/provider.dart'; 5 5 6 6 import '../services/auth_service.dart'; 7 - import '../services/onboarding_service.dart'; 8 7 9 8 class SplashScreen extends StatefulWidget { 10 9 const SplashScreen({super.key}); ··· 44 43 final bool isSessionValid = await authService.validateSession(); 45 44 46 45 if (!mounted) return; 47 - if (!isSessionValid) { 48 - Navigator.of(context).pushReplacementNamed('/auth'); 49 - return; 50 - } 51 - // Check if Spark profile exists 52 - final onboardingService = OnboardingService(authService); 53 - final hasSpark = await onboardingService.hasSparkProfile(); 54 - if (!mounted) return; 55 - final nextRoute = hasSpark ? '/home' : '/onboarding'; 56 - Navigator.of(context).pushReplacementNamed(nextRoute); 46 + 47 + Navigator.of(context).pushReplacementNamed(isSessionValid ? '/home' : '/auth'); 57 48 } 58 49 59 50 @override
+47 -28
lib/services/actions_service.dart
··· 5 5 6 6 import '../models/feed_post.dart'; 7 7 import 'auth_service.dart'; 8 + import 'settings_service.dart'; 8 9 import 'sprk_client.dart'; 9 10 10 11 class ActionsService extends ChangeNotifier { 11 12 final AuthService _authService; 13 + final SettingsService _settingsService; 12 14 late final SprkClient _client; 13 15 14 - ActionsService(this._authService) { 16 + ActionsService(this._authService, this._settingsService) { 15 17 _client = SprkClient(_authService); 16 18 } 17 19 ··· 205 207 } 206 208 207 209 Future<dynamic> followUser(String did) async { 208 - // Check if already following 209 210 try { 210 - // Query existing follow records 211 - final existingFollows = await _client.repo.listRecords( 212 - repo: _authService.session!.did, 213 - collection: NSID.parse('so.sprk.graph.follow'), 214 - ); 215 - 216 - // Check if we're already following this specific user 217 - for (final record in existingFollows.data.records) { 218 - if (record.value['subject'] == did) { 219 - throw Exception('Already following this user'); 211 + final mode = _settingsService.followMode; 212 + if (mode == FollowMode.sprk) { 213 + // Check if already following in Spark 214 + final existingFollows = await _client.repo.listRecords( 215 + repo: _authService.session!.did, 216 + collection: NSID.parse('so.sprk.graph.follow'), 217 + ); 218 + for (final record in existingFollows.data.records) { 219 + if (record.value['subject'] == did) { 220 + throw Exception('Already following this user'); 221 + } 222 + } 223 + final followRecord = { 224 + "\$type": "so.sprk.graph.follow", 225 + "subject": did, 226 + "createdAt": DateTime.now().toUtc().toIso8601String(), 227 + }; 228 + final response = await _client.repo.createRecord(collection: NSID.parse('so.sprk.graph.follow'), record: followRecord); 229 + if (response.status.code != 200) { 230 + throw Exception('Failed to follow user: \\${response.status.code} \\${response.data}'); 231 + } 232 + notifyListeners(); 233 + return response; 234 + } else { 235 + // Bluesky mode 236 + final session = _authService.session; 237 + if (session == null) throw Exception('Not authenticated'); 238 + // Check if already following in Bluesky 239 + final followsRes = await _client.repo.listRecords(repo: session.did, collection: NSID.parse('app.bsky.graph.follow')); 240 + for (final record in followsRes.data.records) { 241 + if (record.value['subject'] == did) { 242 + throw Exception('Already following this user'); 243 + } 244 + } 245 + final followRecord = { 246 + "\$type": "app.bsky.graph.follow", 247 + "subject": did, 248 + "createdAt": DateTime.now().toUtc().toIso8601String(), 249 + }; 250 + final response = await _client.repo.createRecord(collection: NSID.parse('app.bsky.graph.follow'), record: followRecord); 251 + if (response.status.code != 200) { 252 + throw Exception('Failed to follow user (bsky): \\${response.status.code} \\${response.data}'); 220 253 } 254 + notifyListeners(); 255 + return response; 221 256 } 222 - 223 - // If not already following, create new follow record 224 - final followRecord = { 225 - "\$type": "so.sprk.graph.follow", 226 - "subject": did, 227 - "createdAt": DateTime.now().toUtc().toIso8601String(), 228 - }; 229 - 230 - final response = await _client.repo.createRecord(collection: NSID.parse('so.sprk.graph.follow'), record: followRecord); 231 - 232 - if (response.status.code != 200) { 233 - throw Exception('Failed to follow user: ${response.status.code} ${response.data}'); 234 - } 235 - 236 - notifyListeners(); 237 - return response; 238 257 } catch (e) { 239 258 debugPrint('Error in followUser: $e'); 240 259 rethrow;
+180
lib/services/onboarding_service.dart
··· 1 + import 'dart:typed_data'; 2 + 1 3 import 'package:atproto/core.dart'; 2 4 import 'package:bluesky/bluesky.dart' as bs; 3 5 ··· 8 10 class OnboardingService { 9 11 final AuthService _authService; 10 12 final SprkClient _sprkClient; 13 + 14 + /// Maximum number of writes allowed in a single applyWrites request 15 + static const int _maxWritesPerRequest = 200; 11 16 12 17 OnboardingService(this._authService) : _sprkClient = SprkClient(_authService); 13 18 ··· 60 65 if (response.status.code != 200) { 61 66 throw Exception('Failed to create Spark profile: ${response.status.code} ${response.data}'); 62 67 } 68 + } 69 + 70 + /// Finalizes the profile creation process including avatar upload. 71 + Future<void> finalizeProfileCreation({ 72 + required String displayName, 73 + required String description, 74 + dynamic avatar, // This can be Uint8List or existing profile data 75 + }) async { 76 + dynamic avatarToSend = avatar; 77 + if (avatar is List<int>) { 78 + final resp = await _sprkClient.repo.uploadBlob(avatar as Uint8List); 79 + if (resp.status.code != 200) { 80 + throw Exception('Failed to upload avatar blob: ${resp.status.code}'); 81 + } 82 + avatarToSend = resp.data.blob.toJson(); 83 + } 84 + 85 + await importCustomProfile(displayName: displayName, description: description, avatar: avatarToSend); 63 86 } 64 87 65 88 /// Fetches the list of DIDs that the user follows on Bluesky ··· 86 109 if (response.status.code != 200) { 87 110 throw Exception('Failed to create Spark follow: ${response.status.code}'); 88 111 } 112 + } 113 + 114 + /// Helper function to apply writes in chunks of 200 (max allowed per request) 115 + Future<void> _applyWritesInChunks(List<Map<String, dynamic>> writes, String did) async { 116 + final atproto = _authService.atproto; 117 + if (atproto == null) throw Exception('Not authenticated'); 118 + 119 + // Split writes into chunks of max 200 items 120 + for (int i = 0; i < writes.length; i += _maxWritesPerRequest) { 121 + final end = (i + _maxWritesPerRequest < writes.length) ? i + _maxWritesPerRequest : writes.length; 122 + final chunk = writes.sublist(i, end); 123 + 124 + final response = await atproto.post(NSID.parse('com.atproto.repo.applyWrites'), body: {'repo': did, 'writes': chunk}); 125 + 126 + if (response.status.code != 200) { 127 + throw Exception('Failed to apply writes: ${response.status.code}'); 128 + } 129 + } 130 + } 131 + 132 + /// Creates multiple follow records in chunks using applyWrites 133 + /// Returns a list of DIDs that were successfully followed 134 + Future<List<String>> createBatchFollows(List<String> subjects) async { 135 + if (subjects.isEmpty) return []; 136 + 137 + final session = _authService.session; 138 + if (session == null) throw Exception('Not authenticated'); 139 + 140 + // Create write operations for each follow 141 + final writes = 142 + subjects.map((subject) { 143 + return { 144 + '\$type': 'com.atproto.repo.applyWrites#create', 145 + 'collection': 'so.sprk.graph.follow', 146 + 'value': { 147 + '\$type': 'so.sprk.graph.follow', 148 + 'subject': subject, 149 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 150 + }, 151 + }; 152 + }).toList(); 153 + 154 + // Apply writes in chunks 155 + await _applyWritesInChunks(writes, session.did); 156 + 157 + return subjects; 158 + } 159 + 160 + /// Fetches the user's current Spark follows from their PDS 161 + /// Returns a set of DIDs that the user follows 162 + Future<Set<String>> getCurrentSparkFollows() async { 163 + final session = _authService.session; 164 + final atproto = _authService.atproto; 165 + 166 + if (session == null || atproto == null) throw Exception('Not authenticated'); 167 + 168 + final followedDids = <String>{}; 169 + String? cursor; 170 + 171 + do { 172 + final response = await atproto.repo.listRecords( 173 + repo: session.did, 174 + collection: NSID.parse('so.sprk.graph.follow'), 175 + cursor: cursor, 176 + limit: 100, 177 + ); 178 + 179 + if (response.status.code != 200) { 180 + throw Exception('Failed to list Spark follows: ${response.status.code}'); 181 + } 182 + 183 + for (final record in response.data.records) { 184 + // Convert to a Map to access the value field 185 + final recordMap = record.toJson(); 186 + final value = recordMap['value'] as Map<String, dynamic>; 187 + final subject = value['subject'] as String; 188 + followedDids.add(subject); 189 + } 190 + 191 + cursor = response.data.cursor; 192 + } while (cursor != null); 193 + 194 + return followedDids; 195 + } 196 + 197 + /// Cleanup duplicate follow records to ensure unique subject values 198 + /// This function detects and removes duplicate follow records from the user's PDS 199 + Future<int> gambiarraFixDuplicates() async { 200 + final session = _authService.session; 201 + final atproto = _authService.atproto; 202 + 203 + if (session == null || atproto == null) throw Exception('Not authenticated'); 204 + 205 + // Fetch all follow records 206 + final allFollows = <Map<String, dynamic>>[]; 207 + String? cursor; 208 + 209 + do { 210 + final response = await atproto.repo.listRecords( 211 + repo: session.did, 212 + collection: NSID.parse('so.sprk.graph.follow'), 213 + cursor: cursor, 214 + limit: 100, 215 + ); 216 + 217 + if (response.status.code != 200) { 218 + throw Exception('Failed to list follow records: ${response.status.code}'); 219 + } 220 + 221 + for (final record in response.data.records) { 222 + allFollows.add(record.toJson()); 223 + } 224 + 225 + cursor = response.data.cursor; 226 + } while (cursor != null); 227 + 228 + // Find duplicates: group by subject and keep track of records to delete 229 + final subjectToRecords = <String, List<Map<String, dynamic>>>{}; 230 + 231 + for (final record in allFollows) { 232 + final subject = record['value']['subject'] as String; 233 + subjectToRecords[subject] ??= []; 234 + subjectToRecords[subject]!.add(record); 235 + } 236 + 237 + // Prepare delete operations for duplicate records 238 + // For each subject, sort by createdAt (oldest first) and keep the oldest record 239 + final deleteWrites = <Map<String, dynamic>>[]; 240 + 241 + for (final entry in subjectToRecords.entries) { 242 + final records = entry.value; 243 + if (records.length > 1) { 244 + // Sort records by createdAt timestamp (oldest first) 245 + records.sort((a, b) { 246 + final aTimestamp = a['value']['createdAt'] as String; 247 + final bTimestamp = b['value']['createdAt'] as String; 248 + return aTimestamp.compareTo(bTimestamp); 249 + }); 250 + 251 + // Keep the oldest one (first after sorting) and mark others for deletion 252 + for (int i = 1; i < records.length; i++) { 253 + deleteWrites.add({ 254 + '\$type': 'com.atproto.repo.applyWrites#delete', 255 + 'collection': 'so.sprk.graph.follow', 256 + 'rkey': records[i]['uri'].toString().split('/').last, 257 + }); 258 + } 259 + } 260 + } 261 + 262 + // If no duplicates found, return early 263 + if (deleteWrites.isEmpty) return 0; 264 + 265 + // Apply delete operations in chunks 266 + await _applyWritesInChunks(deleteWrites, session.did); 267 + 268 + return deleteWrites.length; 89 269 } 90 270 }
+3 -26
lib/services/profile_service.dart
··· 30 30 try { 31 31 final sprkProfile = await getProfileFullSprk(did, forceRefresh: forceRefresh); 32 32 if (sprkProfile != null) { 33 - final viewer = sprkProfile['viewer'] as Map<dynamic, dynamic>?; 34 33 return Profile.fromSparkProfile({ 35 34 'actor': sprkProfile, 36 - 'viewer': {...?viewer, 'following': existingFollowUri}, 35 + 'viewer': sprkProfile['viewer'] as Map<dynamic, dynamic>? ?? {}, 37 36 'source': 'spark', 38 37 }); 39 38 } ··· 53 52 final profile = Profile.fromBlueskyActor(bskyProfile); 54 53 final counts = bskyProfile.toJson(); 55 54 56 - var followersCount = counts['followersCount'] as int? ?? 0; 57 - var followingCount = counts['followsCount'] as int? ?? 0; 58 - 59 - // Try to enhance with Spark data, but don't fail if these calls fail 60 - final client = SprkClient(_authService); 61 - 62 - try { 63 - final sparkFollowers = await client.graph.getFollowers(did); 64 - 65 - final followers = sparkFollowers.data['followers'] as List; 66 - followersCount += followers.length; 67 - } catch (e) { 68 - debugPrint('Error fetching Spark followers: $e'); 69 - // Continue anyway 70 - } 71 - 72 - try { 73 - final sparkFollows = await client.graph.getFollows(did); 74 - 75 - final follows = sparkFollows.data['follows'] as List; 76 - followingCount += follows.length; 77 - } catch (e) { 78 - debugPrint('Error fetching Spark follows: $e'); 79 - } 55 + final followersCount = counts['followersCount'] as int? ?? 0; 56 + final followingCount = counts['followsCount'] as int? ?? 0; 80 57 81 58 return profile.withCounts({ 82 59 'followersCount': followersCount,
+122 -70
lib/services/settings_service.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:shared_preferences/shared_preferences.dart'; 3 - import 'dart:convert'; 5 + 6 + import 'auth_service.dart'; 7 + import 'sprk_client.dart'; 4 8 5 9 /// Enum to represent the user's preference for a specific label 6 - enum LabelPreference { 7 - show, 8 - warn, 9 - hide, 10 + enum LabelPreference { show, warn, hide } 11 + 12 + /// Enum to represent follow mode options 13 + enum FollowMode { 14 + sprk, 15 + bsky; 16 + 17 + @override 18 + String toString() => name; 10 19 } 11 20 12 21 class SettingsService extends ChangeNotifier { ··· 14 23 static const String _followedLabelersKey = 'followed_labelers'; 15 24 static const String _labelerPreferencesKey = 'labeler_preferences'; 16 25 static const String _hideAdultContentKey = 'hide_adult_content'; 17 - 26 + static const String _keyFollowMode = 'profile_follow_mode'; 27 + 18 28 SharedPreferences? _prefs; 19 29 bool _isLoading = true; 20 30 bool _feedBlurEnabled = false; 21 - bool _hideAdultContent = true; // On by default 31 + bool _hideAdultContent = true; 22 32 List<String> _followedLabelers = []; 23 - 33 + FollowMode _followMode = FollowMode.sprk; 34 + final AuthService _authService; 35 + final SprkClient _sprkClient; 36 + 24 37 /// Stores label preferences for each labeler 25 38 /// Format: {labelerDid: {labelValue: preferenceValue}} 26 39 Map<String, Map<String, String>> _labelPreferences = {}; 27 40 28 - SettingsService() { 41 + SettingsService({required AuthService authService}) : _authService = authService, _sprkClient = SprkClient(authService) { 29 42 _loadSettings(); 43 + // Listen for auth state changes to clear cached values on logout 44 + _authService.addListener(_handleAuthStateChange); 45 + } 46 + 47 + @override 48 + void dispose() { 49 + _authService.removeListener(_handleAuthStateChange); 50 + super.dispose(); 51 + } 52 + 53 + void _handleAuthStateChange() { 54 + if (!_authService.isAuthenticated) { 55 + _clearCachedFollowMode(); 56 + } 57 + } 58 + 59 + void _clearCachedFollowMode() { 60 + _prefs?.remove(_keyFollowMode); 30 61 } 31 62 32 63 bool get isLoading => _isLoading; 33 64 bool get feedBlurEnabled => _feedBlurEnabled; 34 65 bool get hideAdultContent => _hideAdultContent; 35 66 List<String> get followedLabelers => List.unmodifiable(_followedLabelers); 36 - 67 + FollowMode get followMode => _followMode; 68 + 37 69 /// Returns an immutable copy of all labeler preferences 38 - Map<String, Map<String, String>> get labelPreferences => 39 - Map.unmodifiable(_labelPreferences); 70 + Map<String, Map<String, String>> get labelPreferences => Map.unmodifiable(_labelPreferences); 40 71 41 72 Future<void> _loadSettings() async { 42 73 _prefs = await SharedPreferences.getInstance(); 43 74 _feedBlurEnabled = _prefs?.getBool(_feedBlurKey) ?? false; 44 - _hideAdultContent = _prefs?.getBool(_hideAdultContentKey) ?? true; // Default to true if not set 75 + _hideAdultContent = _prefs?.getBool(_hideAdultContentKey) ?? true; // Default to true if not set 45 76 _followedLabelers = _prefs?.getStringList(_followedLabelersKey) ?? []; 46 - 77 + 78 + // Load the cached follow mode as a temporary value 79 + final savedMode = _prefs?.getString(_keyFollowMode) ?? 'sprk'; 80 + _followMode = savedMode == 'bsky' ? FollowMode.bsky : FollowMode.sprk; 81 + 47 82 // Load label preferences 48 83 final prefsJson = _prefs?.getString(_labelerPreferencesKey); 49 84 if (prefsJson != null) { 50 85 final Map<String, dynamic> decoded = jsonDecode(prefsJson); 51 - _labelPreferences = decoded.map((key, value) => MapEntry( 52 - key, 53 - (value as Map<String, dynamic>).map((k, v) => MapEntry(k, v.toString())), 54 - )); 86 + _labelPreferences = decoded.map( 87 + (key, value) => MapEntry(key, (value as Map<String, dynamic>).map((k, v) => MapEntry(k, v.toString()))), 88 + ); 55 89 } 56 - 90 + 57 91 _isLoading = false; 58 92 notifyListeners(); 59 93 } ··· 64 98 await _prefs?.setBool(_feedBlurKey, value); 65 99 notifyListeners(); 66 100 } 67 - 101 + 68 102 Future<void> setHideAdultContent(bool value) async { 69 103 if (_isLoading) await _loadSettings(); 70 104 _hideAdultContent = value; 71 105 await _prefs?.setBool(_hideAdultContentKey, value); 72 106 notifyListeners(); 73 107 } 74 - 108 + 75 109 Future<void> setFollowedLabelers(List<String> labelerDids) async { 76 110 if (_isLoading) await _loadSettings(); 77 111 _followedLabelers = List<String>.from(labelerDids); 78 112 await _prefs?.setStringList(_followedLabelersKey, _followedLabelers); 79 113 notifyListeners(); 80 114 } 81 - 115 + 82 116 Future<void> addFollowedLabeler(String labelerDid) async { 83 117 if (_isLoading) await _loadSettings(); 84 118 if (!_followedLabelers.contains(labelerDid)) { ··· 87 121 notifyListeners(); 88 122 } 89 123 } 90 - 124 + 91 125 Future<void> removeFollowedLabeler(String labelerDid) async { 92 126 if (_isLoading) await _loadSettings(); 93 127 if (_followedLabelers.contains(labelerDid)) { 94 128 _followedLabelers.remove(labelerDid); 95 129 await _prefs?.setStringList(_followedLabelersKey, _followedLabelers); 96 - 130 + 97 131 // Also remove preferences for this labeler 98 132 _labelPreferences.remove(labelerDid); 99 133 await _saveLabelPreferences(); 100 - 134 + 101 135 notifyListeners(); 102 136 } 103 137 } 104 - 138 + 105 139 /// Saves label preferences to SharedPreferences 106 140 Future<void> _saveLabelPreferences() async { 107 141 if (_prefs == null) return; 108 - 142 + 109 143 final jsonStr = jsonEncode(_labelPreferences); 110 144 await _prefs!.setString(_labelerPreferencesKey, jsonStr); 111 145 } 112 - 146 + 113 147 /// Sets a preference for a specific label from a labeler 114 - Future<void> setLabelPreference( 115 - String labelerDid, 116 - String labelValue, 117 - LabelPreference preference 118 - ) async { 148 + Future<void> setLabelPreference(String labelerDid, String labelValue, LabelPreference preference) async { 119 149 if (_isLoading) await _loadSettings(); 120 - 150 + 121 151 // Ensure the labeler is initialized in the map 122 152 _labelPreferences[labelerDid] ??= {}; 123 - 153 + 124 154 // Set the preference 125 155 _labelPreferences[labelerDid]![labelValue] = preference.name; 126 - 156 + 127 157 // Save to SharedPreferences 128 158 await _saveLabelPreferences(); 129 159 notifyListeners(); 130 160 } 131 - 161 + 132 162 /// Removes a preference for a specific label, reverting to the default 133 - Future<void> removeLabelPreference( 134 - String labelerDid, 135 - String labelValue 136 - ) async { 163 + Future<void> removeLabelPreference(String labelerDid, String labelValue) async { 137 164 if (_isLoading) await _loadSettings(); 138 - 165 + 139 166 // Check if the labeler and preference exist 140 167 if (_labelPreferences.containsKey(labelerDid)) { 141 168 // Remove the specific preference 142 169 _labelPreferences[labelerDid]?.remove(labelValue); 143 - 170 + 144 171 // Save to SharedPreferences 145 172 await _saveLabelPreferences(); 146 173 notifyListeners(); 147 174 } 148 175 } 149 - 176 + 150 177 /// Gets the preference for a specific label from a labeler 151 178 /// Returns null if no preference is defined 152 179 LabelPreference? getLabelPreference(String labelerDid, String labelValue) { 153 180 if (_isLoading || !_labelPreferences.containsKey(labelerDid)) { 154 181 return null; 155 182 } 156 - 183 + 157 184 final prefValue = _labelPreferences[labelerDid]?[labelValue]; 158 185 if (prefValue == null) return null; 159 - 186 + 160 187 return LabelPreference.values.firstWhere( 161 - (e) => e.name == prefValue, 162 - orElse: () => LabelPreference.warn // default 188 + (e) => e.name == prefValue, 189 + orElse: () => LabelPreference.warn, // default 163 190 ); 164 191 } 165 - 192 + 166 193 /// Gets the preference for a specific label, or returns the default setting from the label definition 167 - LabelPreference getLabelPreferenceOrDefault( 168 - String labelerDid, 169 - String labelValue, 170 - Map<String, dynamic>? labelDefinition 171 - ) { 194 + LabelPreference getLabelPreferenceOrDefault(String labelerDid, String labelValue, Map<String, dynamic>? labelDefinition) { 172 195 // First try to get user's explicit preference 173 196 final userPreference = getLabelPreference(labelerDid, labelValue); 174 197 if (userPreference != null) { 175 198 return userPreference; 176 199 } 177 - 200 + 178 201 // If no user preference and we have a label definition with defaultSetting 179 202 if (labelDefinition != null && labelDefinition.containsKey('defaultSetting')) { 180 203 final defaultSetting = labelDefinition['defaultSetting'] as String; 181 - 204 + 182 205 // Map the defaultSetting string to LabelPreference 183 206 switch (defaultSetting) { 184 207 case 'show': ··· 191 214 return LabelPreference.warn; // Fallback default 192 215 } 193 216 } 194 - 217 + 195 218 // Final fallback 196 219 return LabelPreference.warn; 197 220 } 198 - 221 + 199 222 /// Sets preferences in bulk for all labels from a labeler 200 - Future<void> setLabelerPreferences( 201 - String labelerDid, 202 - Map<String, LabelPreference> preferences 203 - ) async { 223 + Future<void> setLabelerPreferences(String labelerDid, Map<String, LabelPreference> preferences) async { 204 224 if (_isLoading) await _loadSettings(); 205 - 225 + 206 226 // Convert the map of enums to strings 207 - final stringPrefs = preferences.map( 208 - (key, value) => MapEntry(key, value.name) 209 - ); 210 - 227 + final stringPrefs = preferences.map((key, value) => MapEntry(key, value.name)); 228 + 211 229 _labelPreferences[labelerDid] = stringPrefs; 212 230 await _saveLabelPreferences(); 213 231 notifyListeners(); 214 232 } 215 - 233 + 216 234 /// Removes all preferences for a specific labeler 217 235 Future<void> clearLabelerPreferences(String labelerDid) async { 218 236 if (_isLoading) await _loadSettings(); 219 - 237 + 220 238 _labelPreferences.remove(labelerDid); 221 239 await _saveLabelPreferences(); 222 240 notifyListeners(); 223 241 } 224 - } 242 + 243 + /// Fetch and sync the follow mode from the backend, store locally, and notify listeners if changed. 244 + Future<void> syncFollowModeFromServer() async { 245 + if (_isLoading) await _loadSettings(); 246 + try { 247 + final response = await _sprkClient.actor.getPreferences(); 248 + final serverMode = response.data['followMode'] ?? 'sprk'; 249 + final mode = serverMode == 'bsky' ? FollowMode.bsky : FollowMode.sprk; 250 + if (_followMode != mode) { 251 + _followMode = mode; 252 + await _prefs?.setString(_keyFollowMode, mode.name); 253 + notifyListeners(); 254 + } 255 + } catch (e) { 256 + debugPrint('Failed to sync follow mode from server: $e'); 257 + } 258 + } 259 + 260 + /// Sets the profile follow mode, saves it, and notifies listeners. 261 + Future<void> setFollowMode(FollowMode mode) async { 262 + if (_isLoading) await _loadSettings(); 263 + 264 + _followMode = mode; 265 + await _prefs?.setString(_keyFollowMode, mode.name); 266 + 267 + // Call the API to update the server-side preference 268 + try { 269 + await _sprkClient.actor.putPreferences(followMode: mode); 270 + } catch (e) { 271 + debugPrint('Failed to update server preference: $e'); 272 + } 273 + 274 + notifyListeners(); 275 + } 276 + }
+46
lib/services/sprk_client.dart
··· 5 5 import 'package:sparksocial/config/app_config.dart'; 6 6 7 7 import 'auth_service.dart'; 8 + import 'settings_service.dart'; 8 9 9 10 /// Client for interacting with Spark API endpoints 10 11 class SprkClient { ··· 205 206 return await atproto.get( 206 207 NSID.parse('so.sprk.actor.searchActors'), 207 208 parameters: {'q': query}, 209 + headers: {'atproto-proxy': _client._sprkDid}, 210 + to: (jsonMap) => jsonMap, 211 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 212 + ); 213 + }); 214 + } 215 + 216 + /// Set user preferences 217 + /// 218 + /// [followMode] The follow mode to set (FollowMode.bsky or FollowMode.sprk) 219 + Future<dynamic> putPreferences({required FollowMode followMode}) async { 220 + return _client._executeWithRetry(() async { 221 + if (!_client._authService.isAuthenticated) { 222 + throw Exception('Not authenticated'); 223 + } 224 + 225 + final atproto = _client._authService.atproto; 226 + if (atproto == null) { 227 + throw Exception('AtProto not initialized'); 228 + } 229 + 230 + return await atproto.post( 231 + NSID.parse('so.sprk.actor.putPreferences'), 232 + body: {'followMode': followMode.name}, 233 + headers: {'atproto-proxy': _client._sprkDid}, 234 + ); 235 + }); 236 + } 237 + 238 + /// Get user preferences 239 + /// 240 + /// Returns the user's preferences, including followMode 241 + Future<dynamic> getPreferences() async { 242 + return _client._executeWithRetry(() async { 243 + if (!_client._authService.isAuthenticated) { 244 + throw Exception('Not authenticated'); 245 + } 246 + 247 + final atproto = _client._authService.atproto; 248 + if (atproto == null) { 249 + throw Exception('AtProto not initialized'); 250 + } 251 + 252 + return await atproto.get( 253 + NSID.parse('so.sprk.actor.getPreferences'), 208 254 headers: {'atproto-proxy': _client._sprkDid}, 209 255 to: (jsonMap) => jsonMap, 210 256 adaptor: (uint8) => jsonDecode(utf8.decode(uint8)),
-97
lib/widgets/profile/profile_menu_sheet.dart
··· 1 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 - import 'package:flutter/material.dart'; 3 - 4 - import '../../utils/app_colors.dart'; 5 - 6 - class ProfileMenuSheet extends StatelessWidget { 7 - final VoidCallback onLogout; 8 - 9 - const ProfileMenuSheet({required this.onLogout, super.key}); 10 - 11 - @override 12 - Widget build(BuildContext context) { 13 - final brightness = MediaQuery.of(context).platformBrightness; 14 - final isDarkMode = brightness == Brightness.dark; 15 - 16 - return Container( 17 - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), 18 - decoration: BoxDecoration( 19 - color: isDarkMode ? AppColors.deepPurple : Colors.white, 20 - borderRadius: const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), 21 - ), 22 - child: Column( 23 - mainAxisSize: MainAxisSize.min, 24 - children: [ 25 - // Drag indicator 26 - Container( 27 - width: 40, 28 - height: 4, 29 - margin: const EdgeInsets.only(bottom: 24), 30 - decoration: BoxDecoration(color: Colors.grey[400], borderRadius: BorderRadius.circular(2)), 31 - ), 32 - 33 - // Title 34 - Text( 35 - 'Profile Options', 36 - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: isDarkMode ? Colors.white : Colors.black), 37 - ), 38 - 39 - const SizedBox(height: 32), 40 - 41 - // Logout Button 42 - _buildMenuButton( 43 - context, 44 - icon: FluentIcons.sign_out_24_filled, 45 - label: 'Logout', 46 - textColor: Colors.red, 47 - onTap: () { 48 - Navigator.pop(context); 49 - onLogout(); 50 - }, 51 - ), 52 - 53 - const SizedBox(height: 16), 54 - 55 - // Cancel Button 56 - _buildMenuButton(context, icon: FluentIcons.dismiss_20_filled, label: 'Cancel', onTap: () => Navigator.pop(context)), 57 - 58 - const SizedBox(height: 16), 59 - ], 60 - ), 61 - ); 62 - } 63 - 64 - Widget _buildMenuButton( 65 - BuildContext context, { 66 - required IconData icon, 67 - required String label, 68 - required VoidCallback onTap, 69 - Color? textColor, 70 - }) { 71 - final brightness = MediaQuery.of(context).platformBrightness; 72 - final isDarkMode = brightness == Brightness.dark; 73 - 74 - return InkWell( 75 - onTap: onTap, 76 - borderRadius: BorderRadius.circular(12), 77 - child: Container( 78 - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), 79 - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), 80 - child: Row( 81 - children: [ 82 - Icon(icon, color: textColor ?? (isDarkMode ? Colors.white : Colors.black87), size: 24), 83 - const SizedBox(width: 16), 84 - Text( 85 - label, 86 - style: TextStyle( 87 - fontSize: 16, 88 - fontWeight: FontWeight.w500, 89 - color: textColor ?? (isDarkMode ? Colors.white : Colors.black87), 90 - ), 91 - ), 92 - ], 93 - ), 94 - ), 95 - ); 96 - } 97 - }