diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 48e6b2b..8ea2e6b 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -315,72 +315,75 @@ class FeedRepository { return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); } + /// When refreshing, the newest page goes first + /// + /// Cache writes are best-effort and must never break feed rendering. Future _cacheFeedWindow({required String feedKey, required FeedResult result, required String? cursor}) async { - final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); + try { + final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); - final merged = []; - final seen = {}; + final merged = []; + final seen = {}; - void addPost(FeedViewPost post) { - final uri = post.post.uri.toString(); - if (seen.add(uri)) { - merged.add(post); + void addPost(FeedViewPost post) { + final uri = post.post.uri.toString(); + if (seen.add(uri)) { + merged.add(post); + } } - } - if (cursor == null) { - // Refresh: newest page goes first. - for (final post in result.posts) { - addPost(post); - } - for (final cached in existingPosts) { - if (seen.contains(cached.postUri)) { - continue; + if (cursor == null) { + for (final post in result.posts) { + addPost(post); + } + for (final cached in existingPosts) { + if (seen.contains(cached.postUri)) { + continue; + } + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } + } else { + for (final cached in existingPosts) { + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } + for (final post in result.posts) { + addPost(post); } - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); - } - } else { - // Pagination: append older page at the end. - for (final cached in existingPosts) { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); } - for (final post in result.posts) { - addPost(post); + + final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); + final companions = []; + for (var i = 0; i < limited.length; i++) { + final post = limited[i]; + final uri = post.post.uri.toString(); + final sortOrder = OfflineCachePolicy.feedPostLimit - i; + companions.add( + CachedFeedPostsCompanion.insert( + accountDid: _accountDid, + feedKey: feedKey, + postUri: uri, + postJson: jsonEncode(post.toJson()), + sortOrder: sortOrder, + ), + ); } - } - final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); - final companions = []; - for (var i = 0; i < limited.length; i++) { - final post = limited[i]; - final uri = post.post.uri.toString(); - // Large sort numbers mean newer items come first in DESC sort. - final sortOrder = OfflineCachePolicy.feedPostLimit - i; - companions.add( - CachedFeedPostsCompanion.insert( + await _database.transaction(() async { + await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); + await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); + await _database.cacheFeedPage( accountDid: _accountDid, feedKey: feedKey, - postUri: uri, - postJson: jsonEncode(post.toJson()), - sortOrder: sortOrder, - ), + payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), + ); + }); + } catch (error, stackTrace) { + log.w( + 'feed.cacheWindow failed account=$_accountDid feedKey=$feedKey cursor=$cursor reason=$error', + error: error, + stackTrace: stackTrace, ); } - - await _database.transaction(() async { - await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); - await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); - await _database.cacheFeedPage( - accountDid: _accountDid, - feedKey: feedKey, - payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), - ); - await _database.pruneCachedFeedPosts( - accountDid: _accountDid, - feedKey: feedKey, - maxCount: OfflineCachePolicy.feedPostLimit, - ); - }); } } diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index eb5963d..edf5fee 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -275,6 +275,12 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli } void _onScroll() { + if (!_scrollController.hasClients || !_scrollController.position.hasContentDimensions) { + return; + } + if (_isLoading || _showInitialLoading) { + return; + } if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { _loadMore(); } @@ -319,6 +325,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli _setStateIfMounted(() { _isLoading = true; + _isLoadingMore = false; _showInitialLoading = showLoading; _hasError = false; _errorMessage = null; @@ -340,6 +347,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli if (_posts.isNotEmpty) { _setStateIfMounted(() { _isLoading = false; + _isLoadingMore = false; _showInitialLoading = false; }); return; @@ -347,6 +355,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli _setStateIfMounted(() { _isLoading = false; + _isLoadingMore = false; _showInitialLoading = false; _hasError = true; _errorMessage = e.toString(); @@ -359,7 +368,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli } Future _loadMore() async { - if (_isLoadingMore || _cursor == null) return; + if (_isLoading || _showInitialLoading || _isLoadingMore || _cursor == null) return; if (context.read().state.isOffline) { return; } diff --git a/lib/features/settings/presentation/video_upload_limits_screen.dart b/lib/features/settings/presentation/video_upload_limits_screen.dart index ac5d8aa..6d45847 100644 --- a/lib/features/settings/presentation/video_upload_limits_screen.dart +++ b/lib/features/settings/presentation/video_upload_limits_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; import 'package:lazurite/features/settings/data/video_repository.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; class VideoUploadLimitsScreen extends StatefulWidget { const VideoUploadLimitsScreen({super.key}); @@ -65,15 +66,6 @@ class _VideoUploadLimitsBody extends StatelessWidget { final VideoUploadLimits limits; - String _formatBytes(int bytes) { - if (bytes >= 1024 * 1024 * 1024) { - final gb = bytes / (1024 * 1024 * 1024); - return '${gb.toStringAsFixed(2)} GB'; - } - final mb = bytes / (1024 * 1024); - return '${mb.toStringAsFixed(2)} MB'; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -97,7 +89,7 @@ class _VideoUploadLimitsBody extends StatelessWidget { const Divider(), ], if (limits.remainingDailyBytes != null) ...[ - _LimitRow(label: 'Remaining storage today', value: _formatBytes(limits.remainingDailyBytes!)), + _LimitRow(label: 'Remaining storage today', value: formatBytes(limits.remainingDailyBytes!)), const Divider(), ], if (limits.message != null) ...[ @@ -130,16 +122,14 @@ class _LimitRow extends StatelessWidget { final String value; @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: context.textTheme.bodyLarge), - Text(value, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), - ], - ), - ); - } + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: context.textTheme.bodyLarge), + Text(value, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), + ], + ), + ); } diff --git a/lib/shared/utils/format_utils.dart b/lib/shared/utils/format_utils.dart index 2c52654..91ad1ef 100644 --- a/lib/shared/utils/format_utils.dart +++ b/lib/shared/utils/format_utils.dart @@ -67,3 +67,12 @@ String feedDisplayName(GeneratorView value) { } return value.uri.rkey; } + +String formatBytes(int bytes) { + if (bytes >= 1024 * 1024 * 1024) { + final gb = bytes / (1024 * 1024 * 1024); + return '${gb.toStringAsFixed(2)} GB'; + } + final mb = bytes / (1024 * 1024); + return '${mb.toStringAsFixed(2)} MB'; +} diff --git a/test/shared/utils/format_utils_test.dart b/test/shared/utils/format_utils_test.dart index 8eb12bc..fe67134 100644 --- a/test/shared/utils/format_utils_test.dart +++ b/test/shared/utils/format_utils_test.dart @@ -1,5 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; void main() { @@ -66,4 +69,32 @@ void main() { expect(formatRelativeTime(now.add(const Duration(minutes: 5)), now: now), 'now'); }); }); + + group('feedDisplayName', () { + test('prefers generator displayName when available', () { + final feed = GeneratorView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.generator/test'), + cid: 'cid-1', + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), + did: 'did:plc:test', + displayName: 'What\'s Hot', + indexedAt: DateTime.utc(2026, 3, 16), + ); + + expect(feedDisplayName(feed), 'What\'s Hot'); + }); + + test('falls back to URI rkey when displayName is empty', () { + final feed = GeneratorView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.generator/test'), + cid: 'cid-1', + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), + did: 'did:plc:test', + displayName: ' ', + indexedAt: DateTime.utc(2026, 3, 16), + ); + + expect(feedDisplayName(feed), 'test'); + }); + }); }