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

stories manager (#83)

* manager

* review story mode

* labels

* fix aspect ratio

* default to true pref

* fix null error on images + beter logging

* linter

* update deps

---------

Co-authored-by: Roscoe Rubin-Rottenberg <roscoe@knotbin.com>

authored by

Davi Rodrigues
Roscoe Rubin-Rottenberg
and committed by
GitHub
365dde51 d07c5442

+640 -149
+1 -1
lib/src/core/network/atproto/data/models/records.dart
··· 30 30 required Embed media, 31 31 required DateTime createdAt, 32 32 StrongRef? sound, 33 - List<SelfLabel>? selfLabels, 33 + List<SelfLabel>? labels, 34 34 List<String>? tags, 35 35 }) = StoryRecord; 36 36
+18 -12
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 895 895 896 896 @override 897 897 Future<StrongRef> postStory(Embed embed, {List<SelfLabel>? selfLabels, List<String>? tags}) { 898 + final startedAt = DateTime.now(); 899 + _logger.d('Posting story (embed=${embed.runtimeType}, tags=${tags?.length ?? 0})'); 898 900 return _client.executeWithRetry(() async { 899 901 if (!_client.authRepository.isAuthenticated) { 900 902 _logger.w('Not authenticated'); 901 903 throw Exception('Not authenticated'); 902 904 } 903 905 904 - final record = StoryRecord(createdAt: DateTime.now(), media: embed, selfLabels: selfLabels, tags: tags); 906 + final record = StoryRecord(createdAt: DateTime.now(), media: embed, tags: tags); 907 + try { 908 + final response = await _client.authRepository.atproto!.repo.createRecord( 909 + collection: NSID.parse('so.sprk.feed.story'), 910 + record: record.toJson(), 911 + ); 905 912 906 - final response = await _client.authRepository.atproto!.repo.createRecord( 907 - collection: NSID.parse('so.sprk.feed.story'), 908 - record: record.toJson(), 909 - ); 910 - 911 - if (response.status == HttpStatus.ok) { 912 - _logger.i('Story posted successfully: ${response.data.uri}'); 913 - return response.data; 914 - } else { 915 - _logger.e('Failed to post story: ${response.status} ${response.data}'); 916 - throw Exception('Failed to post story: ${response.status} ${response.data}'); 913 + if (response.status == HttpStatus.ok) { 914 + _logger.i('Story posted in ${DateTime.now().difference(startedAt).inMilliseconds}ms uri=${response.data.uri}'); 915 + return response.data; 916 + } else { 917 + _logger.e('Failed to post story: status=${response.status}'); 918 + throw Exception('Failed to post story: ${response.status} ${response.data}'); 919 + } 920 + } catch (e, s) { 921 + _logger.e('Exception posting story: $e', error: e, stackTrace: s); 922 + rethrow; 917 923 } 918 924 }); 919 925 }
+3
lib/src/core/routing/app_router.dart
··· 94 94 ], 95 95 ), 96 96 97 + // Story Manager 98 + AutoRoute(page: StoryManagerRoute.page, path: '/story-manager'), 99 + 97 100 // Alternate starting routes 98 101 AutoRoute(page: EmptyRoute.page, path: '/empty'), 99 102 AutoRoute(page: LoginRoute.page, path: '/login'),
+1
lib/src/core/routing/pages.dart
··· 29 29 export 'package:sparksocial/src/features/splash/ui/pages/splash_page.dart'; 30 30 export 'package:sparksocial/src/features/stories/ui/pages/all_stories_page.dart'; 31 31 export 'package:sparksocial/src/features/stories/ui/pages/author_stories_page.dart'; 32 + export 'package:sparksocial/src/features/stories/ui/pages/story_manager_page.dart'; 32 33 export 'package:sparksocial/src/features/stories/ui/pages/story_page.dart';
+3
lib/src/core/storage/preferences/storage_constants.dart
··· 29 29 30 30 /// Post to Bluesky 31 31 static const String postToBskyKey = 'post_to_bsky_enabled'; 32 + 33 + /// Story auto deletion 34 + static const String storyAutoDeleteEnabled = 'story_auto_delete_enabled'; 32 35 }
+49 -44
lib/src/features/posting/providers/video_upload_provider.dart
··· 16 16 final feedRepository = GetIt.I<SprkRepository>().feed; 17 17 final logger = GetIt.I<LogService>().getLogger('Processing Video'); 18 18 try { 19 - logger.i('Starting video processing for: $videoPath'); 20 - 19 + logger.d('Processing video: $videoPath'); 21 20 final blob = await feedRepository.uploadVideo(videoPath); 22 - 23 - logger.i('Video processed successfully'); 21 + logger.i('Video processed (size=${blob.size}, mime=${blob.mimeType})'); 24 22 return blob; 25 23 } catch (error, stackTrace) { 26 - logger.e('Error processing video', error: error, stackTrace: stackTrace); 24 + logger.e('Error processing video ($videoPath)', error: error, stackTrace: stackTrace); 27 25 return null; 28 26 } 29 27 } ··· 40 38 }) async { 41 39 final logger = GetIt.I<LogService>().getLogger('Posting Video'); 42 40 try { 41 + logger.d('Posting video (size=${blob.size}, crosspost=$crosspostToBsky)'); 43 42 final authRepository = GetIt.I<AuthRepository>(); 44 - 45 43 final authAtProto = authRepository.atproto; 46 44 if (authAtProto == null || authAtProto.session == null) { 47 45 throw Exception('AtProto not initialized'); 48 46 } 49 47 50 - final postText = description.isNotEmpty ? description : ''; 51 - 52 - // Create a properly formatted AT Protocol post record 53 48 final postRecord = PostRecord( 54 - text: postText, 49 + text: description.isNotEmpty ? description : '', 55 50 embed: EmbedVideo(video: blob, alt: altText), 56 51 createdAt: DateTime.now().toUtc(), 57 52 ); 58 53 59 - // Create the post record 60 54 final recordRes = await authAtProto.repo.createRecord( 61 55 collection: NSID.parse('so.sprk.feed.post'), 62 56 record: postRecord.toJson(), 63 57 ); 64 58 65 59 if (recordRes.status != HttpStatus.ok) { 66 - throw Exception('Failed to post video: ${recordRes.status} ${recordRes.data}'); 60 + throw Exception('Failed to post video: ${recordRes.status}'); 67 61 } 68 62 69 - // Crosspost to Bluesky if enabled 70 63 if (crosspostToBsky) { 71 64 try { 72 - await _crosspostVideoToBlueSky(ref, postText, blob, altText, recordRes.data.uri.rkey); 73 - } catch (e) { 74 - logger.w('Failed to crosspost video to Bluesky: $e'); 75 - // Don't fail the entire operation if Bluesky crossposting fails 65 + await _crosspostVideoToBlueSky(ref, description, blob, altText, recordRes.data.uri.rkey); 66 + } catch (e, s) { 67 + logger.w('Crosspost to Bluesky failed: $e', error: e, stackTrace: s); 76 68 } 77 69 } 78 - logger.i('Video posted successfully'); 70 + logger.i('Video posted successfully: ${recordRes.data.uri}'); 79 71 return recordRes.data; 80 72 } catch (error, stackTrace) { 81 73 logger.e('Error posting video', error: error, stackTrace: stackTrace); ··· 93 85 bool crosspostToBsky = false, 94 86 bool storyMode = false, 95 87 }) async { 88 + final logger = GetIt.I<LogService>().getLogger('Process/Post Video'); 89 + logger.d('Processing then posting video: $videoPath (storyMode=$storyMode)'); 96 90 final blob = await processVideo(ref, videoPath); 97 91 if (blob == null) { 92 + logger.e('Aborting: processing failed'); 98 93 throw Exception('Failed to process video'); 99 94 } 100 95 101 96 if (storyMode) { 102 - // Post as a story 103 - return ref 104 - .read( 105 - postStoryProvider( 106 - EmbedVideo(video: blob), 107 - selfLabels: [], 108 - tags: [], 109 - ), 110 - ) 111 - .value; 97 + try { 98 + final res = await ref.read( 99 + postStoryProvider( 100 + EmbedVideo(video: blob), 101 + selfLabels: [], 102 + tags: [], 103 + ).future, 104 + ); 105 + logger.i('Story posted: ${res?.uri}'); 106 + return res; 107 + } catch (e, s) { 108 + logger.e('Failed to post story', error: e, stackTrace: s); 109 + rethrow; 110 + } 112 111 } else { 113 - // Post as a regular video 114 - return postVideo( 112 + final res = await postVideo( 115 113 ref, 116 114 blob: blob, 117 115 description: description, ··· 119 117 videoPath: videoPath, 120 118 crosspostToBsky: crosspostToBsky, 121 119 ); 120 + logger.i('Video flow complete (storyMode=false) success=${res != null}'); 121 + return res; 122 122 } 123 123 } 124 124 125 125 /// Crosspost video to Bluesky using same blob but Bluesky models 126 126 @riverpod 127 - Future<void> _crosspostVideoToBlueSky(Ref ref, String text, Blob blob, String altText, String rkey) async { 128 - final logger = GetIt.I<LogService>().getLogger('Crossposting Video to Bluesky'); 127 + Future<void> _crosspostVideoToBlueSky( 128 + Ref ref, 129 + String text, 130 + Blob blob, 131 + String altText, 132 + String rkey, 133 + ) async { 134 + final logger = GetIt.I<LogService>().getLogger('Crosspost Video'); 129 135 final authRepository = GetIt.I<AuthRepository>(); 130 136 logger.d('Crossposting video to Bluesky'); 131 - 132 137 final session = authRepository.session; 133 138 if (session == null) { 134 139 throw Exception('No session available for Bluesky crosspost'); 135 140 } 136 - 137 - // Create Bluesky video post record using direct JSON structure 138 141 final bskyPostRecord = <String, dynamic>{ 139 142 r'$type': 'app.bsky.feed.post', 140 143 'text': text, 141 144 'embed': {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': altText}, 142 145 'createdAt': DateTime.now().toUtc().toIso8601String(), 143 146 }; 144 - 145 - final bskyAtProto = authRepository.atproto!; 146 - final bskyResult = await bskyAtProto.repo.createRecord( 147 - collection: NSID.parse('app.bsky.feed.post'), 148 - record: bskyPostRecord, 149 - rkey: rkey, 150 - ); 151 - 152 - logger.i('Successfully crossposted video to Bluesky: ${bskyResult.data.uri}'); 147 + try { 148 + final bskyAtProto = authRepository.atproto!; 149 + final bskyResult = await bskyAtProto.repo.createRecord( 150 + collection: NSID.parse('app.bsky.feed.post'), 151 + record: bskyPostRecord, 152 + rkey: rkey, 153 + ); 154 + logger.i('Crossposted video to Bluesky: ${bskyResult.data.uri}'); 155 + } catch (e, s) { 156 + logger.w('Failed to crosspost video: $e', error: e, stackTrace: s); 157 + } 153 158 }
+68 -58
lib/src/features/posting/ui/pages/image_review_page.dart
··· 121 121 _isPosting = true; 122 122 }); 123 123 try { 124 - final crosspostEnabled = ref.read(settingsProvider).postToBskyEnabled; 124 + final crosspostEnabled = widget.storyMode ? false : ref.read(settingsProvider).postToBskyEnabled; 125 125 final description = _descriptionController.text; 126 126 StrongRef result; 127 127 if (widget.storyMode) { ··· 132 132 if (uploadedImage.isEmpty) { 133 133 throw Exception('No images uploaded'); 134 134 } 135 - result = ref 136 - .read( 137 - postStoryProvider( 138 - Embed.image(images: uploadedImage), 139 - ), 140 - ) 141 - .value!; 135 + final storyProvider = postStoryProvider( 136 + Embed.image(images: uploadedImage), 137 + ); 138 + final asyncResult = await ref.read(storyProvider.future); 139 + if (asyncResult == null) { 140 + throw Exception('Story post returned null StrongRef'); 141 + } 142 + result = asyncResult; 142 143 } else { 143 144 // Post as a regular image post 144 145 result = await _feedRepository.postImages(description, _imageFiles, _altTexts, crosspostToBsky: crosspostEnabled); ··· 218 219 SizedBox(width: 4), 219 220 Text( 220 221 'Tap to edit', 221 - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), 222 + style: TextStyle( 223 + color: Colors.white, 224 + fontSize: 14, 225 + fontWeight: FontWeight.bold, 226 + ), 222 227 ), 223 228 ], 224 229 ), ··· 380 385 }, 381 386 ), 382 387 const SizedBox(height: 20), 383 - // Bluesky Cross-posting Switch 384 - Consumer( 385 - builder: (context, ref, _) { 386 - final settings = ref.watch(settingsProvider); 387 - final showWarning = settings.postToBskyEnabled && _imageFiles.length > 4; 388 - return Column( 389 - children: [ 390 - Container( 391 - decoration: BoxDecoration( 392 - color: Theme.of(context).colorScheme.surface, 393 - borderRadius: BorderRadius.circular(8), 394 - ), 395 - child: ListTile( 396 - title: Text( 397 - 'Post to Bluesky', 398 - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 399 - ), 400 - trailing: Switch( 401 - value: settings.postToBskyEnabled, 402 - onChanged: (bool value) { 403 - ref.read(settingsProvider.notifier).setPostToBsky(value); 404 - }, 405 - activeColor: Theme.of(context).colorScheme.primary, 406 - ), 407 - onTap: () { 408 - ref.read(settingsProvider.notifier).setPostToBsky(!settings.postToBskyEnabled); 409 - }, 410 - ), 411 - ), 412 - if (showWarning) ...[ 413 - const SizedBox(height: 12), 388 + if (!widget.storyMode) 389 + Consumer( 390 + builder: (context, ref, _) { 391 + final settings = ref.watch(settingsProvider); 392 + final showWarning = settings.postToBskyEnabled && _imageFiles.length > 4; 393 + return Column( 394 + children: [ 414 395 Container( 415 - width: double.infinity, 416 - padding: const EdgeInsets.all(12), 417 396 decoration: BoxDecoration( 418 - color: Colors.orange.withAlpha(25), 397 + color: Theme.of(context).colorScheme.surface, 419 398 borderRadius: BorderRadius.circular(8), 420 399 ), 421 - child: const Row( 422 - children: [ 423 - Icon(Icons.info_outline, color: Colors.orange, size: 20), 424 - SizedBox(width: 8), 425 - Expanded( 426 - child: Text( 427 - 'Bluesky supports a maximum of 4 images. Your Bluesky post will link to the full Spark post instead.', 428 - style: TextStyle(color: Colors.orange, fontSize: 13, fontWeight: FontWeight.w500), 400 + child: ListTile( 401 + title: Text( 402 + 'Post to Bluesky', 403 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 404 + ), 405 + trailing: Switch( 406 + value: settings.postToBskyEnabled, 407 + onChanged: (bool value) { 408 + ref.read(settingsProvider.notifier).setPostToBsky(value); 409 + }, 410 + activeColor: Theme.of(context).colorScheme.primary, 411 + ), 412 + onTap: () { 413 + ref.read(settingsProvider.notifier).setPostToBsky(!settings.postToBskyEnabled); 414 + }, 415 + ), 416 + ), 417 + if (showWarning) ...[ 418 + const SizedBox(height: 12), 419 + Container( 420 + width: double.infinity, 421 + padding: const EdgeInsets.all(12), 422 + decoration: BoxDecoration( 423 + color: Colors.orange.withAlpha(25), 424 + borderRadius: BorderRadius.circular(8), 425 + ), 426 + child: const Row( 427 + children: [ 428 + Icon(Icons.info_outline, color: Colors.orange, size: 20), 429 + SizedBox(width: 8), 430 + Expanded( 431 + child: Text( 432 + 'Bluesky supports a maximum of 4 images. Your Bluesky post will link to the full Spark post instead.', 433 + style: TextStyle(color: Colors.orange, fontSize: 13, fontWeight: FontWeight.w500), 434 + ), 429 435 ), 430 - ), 431 - ], 436 + ], 437 + ), 432 438 ), 433 - ), 439 + ], 434 440 ], 435 - ], 436 - ); 437 - }, 438 - ), 441 + ); 442 + }, 443 + ), 439 444 ], 440 445 ), 441 446 ), ··· 451 456 : () async { 452 457 final postRef = await _uploadImagesAndPost(); 453 458 if (context.mounted && postRef != null) { 459 + if (widget.storyMode) { 460 + ScaffoldMessenger.of(context).showSnackBar( 461 + const SnackBar(content: Text('Story posted successfully!')), 462 + ); 463 + } 454 464 context.router.popUntilRoot(); 455 465 final did = ref.read(sessionProvider)?.did; 456 466 if (did != null) {
+29 -29
lib/src/features/posting/ui/pages/video_review_page.dart
··· 70 70 71 71 try { 72 72 final description = _descriptionController.text; 73 - final crosspostEnabled = ref.read(settingsProvider).postToBskyEnabled; 73 + final crosspostEnabled = widget.storyMode ? false : ref.read(settingsProvider).postToBskyEnabled; 74 74 75 75 // Process and post the video with the video upload provider 76 76 final postRef = await ref.read( ··· 292 292 }, 293 293 ), 294 294 const SizedBox(height: 20), 295 - // Bluesky Cross-posting Switch 296 - Consumer( 297 - builder: (context, ref, _) { 298 - final settings = ref.watch(settingsProvider); 299 - return Column( 300 - children: [ 301 - Container( 302 - decoration: BoxDecoration( 303 - color: Theme.of(context).colorScheme.surface, 304 - borderRadius: BorderRadius.circular(8), 305 - ), 306 - child: ListTile( 307 - title: Text( 308 - 'Post to Bluesky', 309 - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 295 + if (!widget.storyMode) 296 + Consumer( 297 + builder: (context, ref, _) { 298 + final settings = ref.watch(settingsProvider); 299 + return Column( 300 + children: [ 301 + Container( 302 + decoration: BoxDecoration( 303 + color: Theme.of(context).colorScheme.surface, 304 + borderRadius: BorderRadius.circular(8), 310 305 ), 311 - trailing: Switch( 312 - value: settings.postToBskyEnabled, 313 - onChanged: (bool value) { 314 - ref.read(settingsProvider.notifier).setPostToBsky(value); 306 + child: ListTile( 307 + title: Text( 308 + 'Post to Bluesky', 309 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 310 + ), 311 + trailing: Switch( 312 + value: settings.postToBskyEnabled, 313 + onChanged: (bool value) { 314 + ref.read(settingsProvider.notifier).setPostToBsky(value); 315 + }, 316 + activeColor: Theme.of(context).colorScheme.primary, 317 + ), 318 + onTap: () { 319 + ref.read(settingsProvider.notifier).setPostToBsky(!settings.postToBskyEnabled); 315 320 }, 316 - activeColor: Theme.of(context).colorScheme.primary, 317 321 ), 318 - onTap: () { 319 - ref.read(settingsProvider.notifier).setPostToBsky(!settings.postToBskyEnabled); 320 - }, 321 322 ), 322 - ), 323 - ], 324 - ); 325 - }, 326 - ), 323 + ], 324 + ); 325 + }, 326 + ), 327 327 ], 328 328 ), 329 329 ),
+102
lib/src/features/stories/providers/story_auto_delete_provider.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:get_it/get_it.dart'; 4 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 6 + import 'package:sparksocial/src/core/storage/preferences/storage_constants.dart'; 7 + import 'package:sparksocial/src/core/storage/preferences/storage_manager.dart'; 8 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 9 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 10 + import 'package:sparksocial/src/features/stories/providers/story_manager_provider.dart'; 11 + 12 + part 'story_auto_delete_provider.g.dart'; 13 + 14 + /// Holds the auto delete preference state (bool) 15 + @riverpod 16 + class StoryAutoDeletePref extends _$StoryAutoDeletePref { 17 + late final SparkLogger _logger; 18 + 19 + @override 20 + Future<bool> build() async { 21 + _logger = GetIt.I<LogService>().getLogger('StoryAutoDeletePref'); 22 + final prefs = StorageManager.instance.preferences; 23 + var stored = await prefs.getBool(StorageKeys.storyAutoDeleteEnabled); 24 + if (stored == null) { 25 + stored = true; 26 + await prefs.setBool(StorageKeys.storyAutoDeleteEnabled, true); 27 + _logger.d('Auto delete preference not found. Setting default to true.'); 28 + } else { 29 + _logger.d('Loaded auto delete preference: $stored'); 30 + } 31 + return stored; 32 + } 33 + 34 + Future<void> setEnabled(bool value) async { 35 + final prefs = StorageManager.instance.preferences; 36 + await prefs.setBool(StorageKeys.storyAutoDeleteEnabled, value); 37 + _logger.d('Set auto delete preference to $value'); 38 + // Update state immutably 39 + state = AsyncData(value); 40 + } 41 + } 42 + 43 + /// Executes auto deletion (once) at startup if enabled. Exposed as a Future provider 44 + /// so that splash / root widgets can await or just watch for side-effects. 45 + @riverpod 46 + Future<void> storyAutoDeleteExecutor(Ref ref) async { 47 + final enabledAsync = await ref.watch(storyAutoDeletePrefProvider.future); 48 + if (!enabledAsync) return; 49 + 50 + final sprk = GetIt.I<SprkRepository>(); 51 + final logger = GetIt.I<LogService>().getLogger('StoryAutoDeleteExec'); 52 + final atproto = sprk.authRepository.atproto; 53 + final did = sprk.authRepository.session?.did; 54 + if (atproto == null || did == null) return; 55 + 56 + try { 57 + const collection = 'so.sprk.feed.story'; 58 + String? cursor; 59 + final expiredUris = <AtUri>[]; 60 + final now = DateTime.now().toUtc(); 61 + do { 62 + final page = await atproto.repo.listRecords( 63 + repo: did, 64 + collection: NSID.parse(collection), 65 + cursor: cursor, 66 + limit: 100, 67 + ); 68 + for (final rec in page.data.records) { 69 + final createdAt = rec.value['createdAt']; 70 + DateTime? ts; 71 + if (createdAt is String) { 72 + ts = DateTime.tryParse(createdAt)?.toUtc(); 73 + } 74 + if (ts != null && now.difference(ts) > const Duration(hours: 24)) { 75 + expiredUris.add(rec.uri); 76 + } 77 + } 78 + cursor = page.data.cursor; 79 + } while (cursor != null); 80 + 81 + if (expiredUris.isEmpty) { 82 + logger.d('Auto delete: no expired stories'); 83 + return; 84 + } 85 + 86 + logger.d('Auto delete: deleting ${expiredUris.length} expired stories'); 87 + for (final uri in expiredUris) { 88 + try { 89 + await sprk.repo.deleteRecord(uri: uri); 90 + } catch (e) { 91 + logger.w('Failed deleting expired story $uri', error: e); 92 + } 93 + } 94 + 95 + // Refresh manager state if it's already loaded 96 + // Refresh story manager provider (will instantiate if not yet) so UI reflects deletions 97 + final manager = ref.read(storyManagerProvider.notifier); 98 + await manager.refresh(); 99 + } catch (e, s) { 100 + GetIt.I<LogService>().getLogger('StoryAutoDeleteExec').e('Auto delete failed', error: e, stackTrace: s); 101 + } 102 + }
+100
lib/src/features/stories/providers/story_manager_provider.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:get_it/get_it.dart'; 3 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 6 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 + import 'package:sparksocial/src/features/stories/providers/story_auto_delete_provider.dart'; 9 + 10 + part 'story_manager_provider.g.dart'; 11 + 12 + /// Simple state holder for the story manager 13 + class StoryManagerState { 14 + StoryManagerState({required this.stories, this.isLoading = false, this.error}); 15 + final List<StoryView> stories; // hydrated story views 16 + final bool isLoading; 17 + final String? error; 18 + 19 + StoryManagerState copyWith({List<StoryView>? stories, bool? isLoading, String? error}) { 20 + return StoryManagerState( 21 + stories: stories ?? this.stories, 22 + isLoading: isLoading ?? this.isLoading, 23 + error: error, 24 + ); 25 + } 26 + } 27 + 28 + @riverpod 29 + class StoryManager extends _$StoryManager { 30 + late final SprkRepository _sprk; 31 + late final SparkLogger _logger; 32 + 33 + @override 34 + Future<StoryManagerState> build() async { 35 + _sprk = GetIt.I<SprkRepository>(); 36 + _logger = GetIt.I<LogService>().getLogger('StoryManager'); 37 + ref.read(storyAutoDeleteExecutorProvider.future).catchError((_) {}); 38 + return _loadInitial(); 39 + } 40 + 41 + Future<StoryManagerState> _loadInitial() async { 42 + try { 43 + final did = _sprk.authRepository.session?.did; 44 + if (did == null) { 45 + return StoryManagerState(stories: const [], error: 'Not authenticated'); 46 + } 47 + // Page through all story records directly via atproto to include expired ( >24h ) ones 48 + final atproto = _sprk.authRepository.atproto; 49 + if (atproto == null) { 50 + return StoryManagerState(stories: const [], error: 'AtProto not initialized'); 51 + } 52 + const collection = 'so.sprk.feed.story'; 53 + String? cursor; 54 + final uris = <AtUri>[]; 55 + do { 56 + final result = await atproto.repo.listRecords( 57 + repo: did, 58 + collection: NSID.parse(collection), 59 + cursor: cursor, 60 + limit: 100, 61 + ); 62 + for (final record in result.data.records) { 63 + uris.add(record.uri); 64 + } 65 + cursor = result.data.cursor; 66 + } while (cursor != null); 67 + if (uris.isEmpty) { 68 + return StoryManagerState(stories: const []); 69 + } 70 + final storyViews = await _sprk.feed.getStoryViews(uris); 71 + 72 + storyViews.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 73 + 74 + return StoryManagerState(stories: storyViews); 75 + } catch (e, s) { 76 + _logger.e('Failed to load stories for manager', error: e, stackTrace: s); 77 + return StoryManagerState(stories: const [], error: e.toString()); 78 + } 79 + } 80 + 81 + Future<void> refresh() async { 82 + state = const AsyncLoading(); 83 + state = await AsyncValue.guard(_loadInitial); 84 + } 85 + 86 + Future<void> deleteStory(StoryView story) async { 87 + final current = state.valueOrNull; 88 + if (current == null) return; 89 + try { 90 + // Optimistic update 91 + final updatedList = List<StoryView>.from(current.stories)..removeWhere((s) => s.uri == story.uri); 92 + state = AsyncData(current.copyWith(stories: updatedList)); 93 + await _sprk.repo.deleteRecord(uri: story.uri); 94 + } catch (e, s) { 95 + _logger.e('Error deleting story', error: e, stackTrace: s); 96 + // Revert by refreshing fully 97 + await refresh(); 98 + } 99 + } 100 + }
+3
lib/src/features/stories/ui/pages/all_stories_page.dart
··· 10 10 required this.storiesByAuthor, 11 11 super.key, 12 12 this.initialAuthorIndex = 0, 13 + this.initialStoryIndex, 13 14 }); 14 15 15 16 final Map<ProfileViewBasic, List<StoryView>> storiesByAuthor; 16 17 final int initialAuthorIndex; 18 + final int? initialStoryIndex; 17 19 18 20 @override 19 21 State<AllStoriesPage> createState() => _AllStoriesPageState(); ··· 51 53 return AuthorStoriesPage( 52 54 author: entry.key, 53 55 stories: entry.value, 56 + initialStoryIndex: index == _currentAuthorIndex ? (widget.initialStoryIndex ?? 0) : 0, 54 57 onPreviousAuthor: index > 0 55 58 ? () => _pageController.previousPage( 56 59 duration: const Duration(milliseconds: 250),
+237
lib/src/features/stories/ui/pages/story_manager_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:cached_network_image/cached_network_image.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 + import 'package:sparksocial/src/core/routing/app_router.dart'; 7 + import 'package:sparksocial/src/features/stories/providers/story_auto_delete_provider.dart'; 8 + import 'package:sparksocial/src/features/stories/providers/story_manager_provider.dart'; 9 + 10 + @RoutePage() 11 + class StoryManagerPage extends ConsumerWidget { 12 + const StoryManagerPage({super.key}); 13 + 14 + void _openStoryViewer(BuildContext context, WidgetRef ref, int index) { 15 + final state = ref.read(storyManagerProvider).valueOrNull; 16 + if (state == null) return; 17 + // Build map required by AllStoriesRoute (single author -> list) 18 + if (state.stories.isEmpty) return; 19 + final author = state.stories.first.author; 20 + context.router.push( 21 + AllStoriesRoute( 22 + storiesByAuthor: {author: state.stories}, 23 + initialStoryIndex: index, 24 + ), 25 + ); 26 + } 27 + 28 + Future<void> _deleteStory(BuildContext context, WidgetRef ref, int index) async { 29 + final notifier = ref.read(storyManagerProvider.notifier); 30 + final stories = ref.read(storyManagerProvider).valueOrNull?.stories ?? []; 31 + if (index >= stories.length) return; 32 + final story = stories[index]; 33 + final shouldDelete = 34 + await showDialog<bool>( 35 + context: context, 36 + builder: (ctx) => AlertDialog( 37 + title: const Text('Delete Story'), 38 + content: const Text('Are you sure you want to delete this story?'), 39 + actions: [ 40 + TextButton(onPressed: () => Navigator.of(ctx).maybePop(false), child: const Text('Cancel')), 41 + TextButton( 42 + onPressed: () => Navigator.of(ctx).maybePop(true), 43 + style: TextButton.styleFrom(foregroundColor: Colors.red), 44 + child: const Text('Delete'), 45 + ), 46 + ], 47 + ), 48 + ) ?? 49 + false; 50 + if (!shouldDelete) return; 51 + await notifier.deleteStory(story); 52 + if (context.mounted) { 53 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Story deleted'))); 54 + } 55 + } 56 + 57 + @override 58 + Widget build(BuildContext context, WidgetRef ref) { 59 + final asyncState = ref.watch(storyManagerProvider); 60 + final theme = Theme.of(context); 61 + 62 + final autoDeletePref = ref.watch(storyAutoDeletePrefProvider); 63 + 64 + return Scaffold( 65 + appBar: AppBar( 66 + title: const Text('Story Manager'), 67 + ), 68 + body: asyncState.when( 69 + data: (data) { 70 + return RefreshIndicator( 71 + onRefresh: () => ref.read(storyManagerProvider.notifier).refresh(), 72 + child: ListView.separated( 73 + padding: const EdgeInsets.all(16), 74 + separatorBuilder: (_, _) => const SizedBox(height: 12), 75 + itemCount: 1 + data.stories.length, // header + stories 76 + itemBuilder: (ctx, i) { 77 + if (i == 0) { 78 + return _AutoDeleteHeader(autoDeletePref: autoDeletePref, ref: ref); 79 + } 80 + final storyIndex = i - 1; 81 + if (data.stories.isEmpty) { 82 + return const SizedBox.shrink(); 83 + } 84 + final story = data.stories[storyIndex]; 85 + final age = DateTime.now().difference(story.indexedAt); 86 + final ageStr = age.inDays >= 1 87 + ? '${age.inDays}d' 88 + : age.inHours >= 1 89 + ? '${age.inHours}h' 90 + : age.inMinutes >= 1 91 + ? '${age.inMinutes}m' 92 + : 'now'; 93 + final thumbUrl = switch (story.media) { 94 + EmbedViewVideo(:final thumbnail) => thumbnail.toString(), 95 + EmbedViewBskyVideo(:final thumbnail) => thumbnail.toString(), 96 + EmbedViewImage(:final images) when images.isNotEmpty => images.first.thumb.toString(), 97 + EmbedViewBskyImages(:final images) when images.isNotEmpty => images.first.thumb.toString(), 98 + EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 99 + EmbedViewVideo(:final thumbnail) => thumbnail.toString(), 100 + EmbedViewBskyVideo(:final thumbnail) => thumbnail.toString(), 101 + EmbedViewImage(:final images) when images.isNotEmpty => images.first.thumb.toString(), 102 + EmbedViewBskyImages(:final images) when images.isNotEmpty => images.first.thumb.toString(), 103 + _ => story.author.avatar.toString(), 104 + }, 105 + _ => story.author.avatar.toString(), 106 + }; 107 + return Material( 108 + color: Colors.transparent, 109 + child: InkWell( 110 + borderRadius: BorderRadius.circular(12), 111 + onTap: () => _openStoryViewer(context, ref, storyIndex), 112 + child: Padding( 113 + padding: const EdgeInsets.symmetric(vertical: 8), 114 + child: Row( 115 + children: [ 116 + ClipRRect( 117 + borderRadius: BorderRadius.circular(8), 118 + child: CachedNetworkImage( 119 + imageUrl: thumbUrl, 120 + width: 56, 121 + height: 56, 122 + fit: BoxFit.cover, 123 + errorWidget: (_, _, _) => Container( 124 + width: 56, 125 + height: 56, 126 + alignment: Alignment.center, 127 + color: theme.colorScheme.surfaceContainerHighest, 128 + child: const Icon(Icons.image_not_supported), 129 + ), 130 + ), 131 + ), 132 + const SizedBox(width: 12), 133 + Expanded( 134 + child: Column( 135 + crossAxisAlignment: CrossAxisAlignment.start, 136 + children: [ 137 + Text('Story ${data.stories.length - storyIndex}', style: theme.textTheme.titleMedium), 138 + const SizedBox(height: 4), 139 + Text('Posted $ageStr ago', style: theme.textTheme.bodySmall), 140 + ], 141 + ), 142 + ), 143 + IconButton( 144 + icon: const Icon(Icons.delete_outline, color: Colors.red), 145 + tooltip: 'Delete', 146 + onPressed: () => _deleteStory(context, ref, storyIndex), 147 + ), 148 + ], 149 + ), 150 + ), 151 + ), 152 + ); 153 + }, 154 + ), 155 + ); 156 + }, 157 + loading: () => const Center(child: CircularProgressIndicator()), 158 + error: (err, _) => Center( 159 + child: Column( 160 + mainAxisSize: MainAxisSize.min, 161 + children: [ 162 + Text('Error: $err'), 163 + const SizedBox(height: 12), 164 + ElevatedButton( 165 + onPressed: () => ref.read(storyManagerProvider.notifier).refresh(), 166 + child: const Text('Retry'), 167 + ), 168 + ], 169 + ), 170 + ), 171 + ), 172 + ); 173 + } 174 + } 175 + 176 + class _AutoDeleteHeader extends StatelessWidget { 177 + const _AutoDeleteHeader({required this.autoDeletePref, required this.ref}); 178 + final AsyncValue<bool> autoDeletePref; 179 + final WidgetRef ref; 180 + 181 + @override 182 + Widget build(BuildContext context) { 183 + final theme = Theme.of(context); 184 + final cardColor = theme.colorScheme.surfaceContainerHighest; 185 + return Container( 186 + decoration: BoxDecoration( 187 + color: cardColor, 188 + borderRadius: BorderRadius.circular(16), 189 + border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.3)), 190 + ), 191 + padding: const EdgeInsets.all(16), 192 + child: Column( 193 + crossAxisAlignment: CrossAxisAlignment.start, 194 + children: [ 195 + Row( 196 + children: [ 197 + Expanded( 198 + child: Text( 199 + 'Auto-delete stories', 200 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 201 + ), 202 + ), 203 + autoDeletePref.when( 204 + data: (enabled) => Switch( 205 + value: enabled, 206 + onChanged: (v) async { 207 + await ref.read(storyAutoDeletePrefProvider.notifier).setEnabled(v); 208 + if (v) { 209 + final f = ref.refresh(storyAutoDeleteExecutorProvider.future); 210 + await f; 211 + await ref.read(storyManagerProvider.notifier).refresh(); 212 + } 213 + }, 214 + ), 215 + loading: () => const SizedBox( 216 + width: 28, 217 + height: 28, 218 + child: CircularProgressIndicator(strokeWidth: 2), 219 + ), 220 + error: (_, _) => IconButton( 221 + icon: const Icon(Icons.refresh), 222 + tooltip: 'Retry', 223 + onPressed: () => ref.refresh(storyAutoDeletePrefProvider), 224 + ), 225 + ), 226 + ], 227 + ), 228 + const SizedBox(height: 8), 229 + Text( 230 + 'Stories are public and stored on your PDS indefinitely. Enable this so the app auto deletes them forever after 24h. Enabling this will also execute an initial cleanup of any stories older than 24h.', 231 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 232 + ), 233 + ], 234 + ), 235 + ); 236 + } 237 + }
+18 -4
lib/src/features/stories/ui/pages/story_page.dart
··· 125 125 126 126 if (_isVideoStory(widget.story)) { 127 127 if (_videoController != null && _isVideoInitialized) { 128 - mediaContent = AspectRatio( 129 - aspectRatio: _videoController!.value.aspectRatio, 130 - child: VideoPlayer(_videoController!), 131 - ); 128 + if (_videoController!.value.isInitialized) { 129 + final size = _videoController!.value.size; 130 + mediaContent = Container( 131 + color: Colors.black, 132 + alignment: Alignment.center, 133 + child: FittedBox( 134 + child: SizedBox( 135 + width: (size.width > 0 ? size.width : 1280), 136 + height: (size.height > 0 ? size.height : 720), 137 + child: VideoPlayer(_videoController!), 138 + ), 139 + ), 140 + ); 141 + } else { 142 + mediaContent = const Center( 143 + child: Icon(Icons.videocam_off, size: 48, color: Colors.white), 144 + ); 145 + } 132 146 } else { 133 147 mediaContent = const Center(child: CircularProgressIndicator()); 134 148 }
+6
lib/src/features/stories/ui/widgets/stories_list.dart
··· 118 118 'Stories', 119 119 style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), 120 120 ), 121 + const Spacer(), 122 + IconButton( 123 + icon: const Icon(Icons.manage_history_outlined, size: 20), 124 + tooltip: 'Manage', 125 + onPressed: () => context.router.push(const StoryManagerRoute()), 126 + ), 121 127 ], 122 128 ), 123 129 ),
+1 -1
pubspec.lock
··· 1611 1611 source: hosted 1612 1612 version: "9.0.0" 1613 1613 video_player: 1614 - dependency: transitive 1614 + dependency: "direct main" 1615 1615 description: 1616 1616 name: video_player 1617 1617 sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a"
+1
pubspec.yaml
··· 46 46 any_link_preview: ^3.0.3 47 47 better_player_plus: ^1.0.8 48 48 flutter_animated_progress_bar: ^1.0.5 49 + video_player: ^2.10.0 49 50 50 51 dev_dependencies: 51 52 flutter_test: