mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

fix: safely allow transactions to retry

+119 -77
+56 -53
lib/features/feed/data/feed_repository.dart
··· 315 315 return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 316 316 } 317 317 318 + /// When refreshing, the newest page goes first 319 + /// 320 + /// Cache writes are best-effort and must never break feed rendering. 318 321 Future<void> _cacheFeedWindow({required String feedKey, required FeedResult result, required String? cursor}) async { 319 - final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); 322 + try { 323 + final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); 320 324 321 - final merged = <FeedViewPost>[]; 322 - final seen = <String>{}; 325 + final merged = <FeedViewPost>[]; 326 + final seen = <String>{}; 323 327 324 - void addPost(FeedViewPost post) { 325 - final uri = post.post.uri.toString(); 326 - if (seen.add(uri)) { 327 - merged.add(post); 328 + void addPost(FeedViewPost post) { 329 + final uri = post.post.uri.toString(); 330 + if (seen.add(uri)) { 331 + merged.add(post); 332 + } 328 333 } 329 - } 330 334 331 - if (cursor == null) { 332 - // Refresh: newest page goes first. 333 - for (final post in result.posts) { 334 - addPost(post); 335 - } 336 - for (final cached in existingPosts) { 337 - if (seen.contains(cached.postUri)) { 338 - continue; 335 + if (cursor == null) { 336 + for (final post in result.posts) { 337 + addPost(post); 338 + } 339 + for (final cached in existingPosts) { 340 + if (seen.contains(cached.postUri)) { 341 + continue; 342 + } 343 + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 344 + } 345 + } else { 346 + for (final cached in existingPosts) { 347 + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 348 + } 349 + for (final post in result.posts) { 350 + addPost(post); 339 351 } 340 - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 341 352 } 342 - } else { 343 - // Pagination: append older page at the end. 344 - for (final cached in existingPosts) { 345 - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 346 - } 347 - for (final post in result.posts) { 348 - addPost(post); 353 + 354 + final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); 355 + final companions = <CachedFeedPostsCompanion>[]; 356 + for (var i = 0; i < limited.length; i++) { 357 + final post = limited[i]; 358 + final uri = post.post.uri.toString(); 359 + final sortOrder = OfflineCachePolicy.feedPostLimit - i; 360 + companions.add( 361 + CachedFeedPostsCompanion.insert( 362 + accountDid: _accountDid, 363 + feedKey: feedKey, 364 + postUri: uri, 365 + postJson: jsonEncode(post.toJson()), 366 + sortOrder: sortOrder, 367 + ), 368 + ); 349 369 } 350 - } 351 370 352 - final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); 353 - final companions = <CachedFeedPostsCompanion>[]; 354 - for (var i = 0; i < limited.length; i++) { 355 - final post = limited[i]; 356 - final uri = post.post.uri.toString(); 357 - // Large sort numbers mean newer items come first in DESC sort. 358 - final sortOrder = OfflineCachePolicy.feedPostLimit - i; 359 - companions.add( 360 - CachedFeedPostsCompanion.insert( 371 + await _database.transaction(() async { 372 + await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); 373 + await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); 374 + await _database.cacheFeedPage( 361 375 accountDid: _accountDid, 362 376 feedKey: feedKey, 363 - postUri: uri, 364 - postJson: jsonEncode(post.toJson()), 365 - sortOrder: sortOrder, 366 - ), 377 + payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), 378 + ); 379 + }); 380 + } catch (error, stackTrace) { 381 + log.w( 382 + 'feed.cacheWindow failed account=$_accountDid feedKey=$feedKey cursor=$cursor reason=$error', 383 + error: error, 384 + stackTrace: stackTrace, 367 385 ); 368 386 } 369 - 370 - await _database.transaction(() async { 371 - await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); 372 - await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); 373 - await _database.cacheFeedPage( 374 - accountDid: _accountDid, 375 - feedKey: feedKey, 376 - payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), 377 - ); 378 - await _database.pruneCachedFeedPosts( 379 - accountDid: _accountDid, 380 - feedKey: feedKey, 381 - maxCount: OfflineCachePolicy.feedPostLimit, 382 - ); 383 - }); 384 387 } 385 388 } 386 389
+10 -1
lib/features/feed/presentation/home_feed_screen.dart
··· 275 275 } 276 276 277 277 void _onScroll() { 278 + if (!_scrollController.hasClients || !_scrollController.position.hasContentDimensions) { 279 + return; 280 + } 281 + if (_isLoading || _showInitialLoading) { 282 + return; 283 + } 278 284 if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 279 285 _loadMore(); 280 286 } ··· 319 325 320 326 _setStateIfMounted(() { 321 327 _isLoading = true; 328 + _isLoadingMore = false; 322 329 _showInitialLoading = showLoading; 323 330 _hasError = false; 324 331 _errorMessage = null; ··· 340 347 if (_posts.isNotEmpty) { 341 348 _setStateIfMounted(() { 342 349 _isLoading = false; 350 + _isLoadingMore = false; 343 351 _showInitialLoading = false; 344 352 }); 345 353 return; ··· 347 355 348 356 _setStateIfMounted(() { 349 357 _isLoading = false; 358 + _isLoadingMore = false; 350 359 _showInitialLoading = false; 351 360 _hasError = true; 352 361 _errorMessage = e.toString(); ··· 359 368 } 360 369 361 370 Future<void> _loadMore() async { 362 - if (_isLoadingMore || _cursor == null) return; 371 + if (_isLoading || _showInitialLoading || _isLoadingMore || _cursor == null) return; 363 372 if (context.read<ConnectivityCubit>().state.isOffline) { 364 373 return; 365 374 }
+13 -23
lib/features/settings/presentation/video_upload_limits_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/theme/theme_extensions.dart'; 3 4 import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 4 5 import 'package:lazurite/features/settings/data/video_repository.dart'; 5 - import 'package:lazurite/core/theme/theme_extensions.dart'; 6 + import 'package:lazurite/shared/utils/format_utils.dart'; 6 7 7 8 class VideoUploadLimitsScreen extends StatefulWidget { 8 9 const VideoUploadLimitsScreen({super.key}); ··· 65 66 66 67 final VideoUploadLimits limits; 67 68 68 - String _formatBytes(int bytes) { 69 - if (bytes >= 1024 * 1024 * 1024) { 70 - final gb = bytes / (1024 * 1024 * 1024); 71 - return '${gb.toStringAsFixed(2)} GB'; 72 - } 73 - final mb = bytes / (1024 * 1024); 74 - return '${mb.toStringAsFixed(2)} MB'; 75 - } 76 - 77 69 @override 78 70 Widget build(BuildContext context) { 79 71 final theme = Theme.of(context); ··· 97 89 const Divider(), 98 90 ], 99 91 if (limits.remainingDailyBytes != null) ...[ 100 - _LimitRow(label: 'Remaining storage today', value: _formatBytes(limits.remainingDailyBytes!)), 92 + _LimitRow(label: 'Remaining storage today', value: formatBytes(limits.remainingDailyBytes!)), 101 93 const Divider(), 102 94 ], 103 95 if (limits.message != null) ...[ ··· 130 122 final String value; 131 123 132 124 @override 133 - Widget build(BuildContext context) { 134 - return Padding( 135 - padding: const EdgeInsets.symmetric(vertical: 8), 136 - child: Row( 137 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 138 - children: [ 139 - Text(label, style: context.textTheme.bodyLarge), 140 - Text(value, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), 141 - ], 142 - ), 143 - ); 144 - } 125 + Widget build(BuildContext context) => Padding( 126 + padding: const EdgeInsets.symmetric(vertical: 8), 127 + child: Row( 128 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 129 + children: [ 130 + Text(label, style: context.textTheme.bodyLarge), 131 + Text(value, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), 132 + ], 133 + ), 134 + ); 145 135 }
+9
lib/shared/utils/format_utils.dart
··· 67 67 } 68 68 return value.uri.rkey; 69 69 } 70 + 71 + String formatBytes(int bytes) { 72 + if (bytes >= 1024 * 1024 * 1024) { 73 + final gb = bytes / (1024 * 1024 * 1024); 74 + return '${gb.toStringAsFixed(2)} GB'; 75 + } 76 + final mb = bytes / (1024 * 1024); 77 + return '${mb.toStringAsFixed(2)} MB'; 78 + }
+31
test/shared/utils/format_utils_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 2 import 'package:intl/intl.dart'; 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 6 import 'package:lazurite/shared/utils/format_utils.dart'; 4 7 5 8 void main() { ··· 64 67 65 68 test('clamps future timestamps to now label', () { 66 69 expect(formatRelativeTime(now.add(const Duration(minutes: 5)), now: now), 'now'); 70 + }); 71 + }); 72 + 73 + group('feedDisplayName', () { 74 + test('prefers generator displayName when available', () { 75 + final feed = GeneratorView( 76 + uri: const AtUri('at://did:plc:test/app.bsky.feed.generator/test'), 77 + cid: 'cid-1', 78 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 79 + did: 'did:plc:test', 80 + displayName: 'What\'s Hot', 81 + indexedAt: DateTime.utc(2026, 3, 16), 82 + ); 83 + 84 + expect(feedDisplayName(feed), 'What\'s Hot'); 85 + }); 86 + 87 + test('falls back to URI rkey when displayName is empty', () { 88 + final feed = GeneratorView( 89 + uri: const AtUri('at://did:plc:test/app.bsky.feed.generator/test'), 90 + cid: 'cid-1', 91 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 92 + did: 'did:plc:test', 93 + displayName: ' ', 94 + indexedAt: DateTime.utc(2026, 3, 16), 95 + ); 96 + 97 + expect(feedDisplayName(feed), 'test'); 67 98 }); 68 99 }); 69 100 }