diff --git a/lib/models/comment.dart b/lib/models/comment.dart index 32692ac..933a21d 100644 --- a/lib/models/comment.dart +++ b/lib/models/comment.dart @@ -391,6 +391,7 @@ class CommentsState { this.isLoading = false, this.isLoadingMore = false, this.error, + this.loadMoreError, }) : comments = List.unmodifiable(comments); /// Create a default empty state @@ -413,13 +414,22 @@ class CommentsState { /// Pagination (load more) in progress final bool isLoadingMore; - /// Error message if any + /// First-page error message, if any. + /// + /// Drives the full-screen error state. A pagination failure must never + /// land here — see [loadMoreError]. final String? error; + /// Pagination error message, if any. + /// + /// Drives the list's footer error, kept separate from [error] so a + /// load-more hiccup cannot blank a list that already has comments. + final String? loadMoreError; + /// Create a copy with modified fields (immutable updates) /// - /// Nullable fields (cursor, error) use a sentinel pattern to distinguish - /// between "not provided" and "explicitly set to null". + /// Nullable fields (cursor, error, loadMoreError) use a sentinel pattern + /// to distinguish between "not provided" and "explicitly set to null". CommentsState copyWith({ List? comments, Object? cursor = _sentinel, @@ -427,6 +437,7 @@ class CommentsState { bool? isLoading, bool? isLoadingMore, Object? error = _sentinel, + Object? loadMoreError = _sentinel, }) { return CommentsState( comments: comments ?? this.comments, @@ -435,6 +446,10 @@ class CommentsState { isLoading: isLoading ?? this.isLoading, isLoadingMore: isLoadingMore ?? this.isLoadingMore, error: error == _sentinel ? this.error : error as String?, + loadMoreError: + loadMoreError == _sentinel + ? this.loadMoreError + : loadMoreError as String?, ); } } diff --git a/lib/models/community.dart b/lib/models/community.dart index e257b85..dee6862 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import '../constants/embed_types.dart'; +import '../utils/url_policy.dart'; /// Response from GET /xrpc/social.coves.community.list class CommunitiesResponse { @@ -222,10 +223,7 @@ class ExternalEmbedInput { } // Validate URI is a well-formed URL - final parsedUri = Uri.tryParse(uri); - if (parsedUri == null || - !parsedUri.hasScheme || - (!parsedUri.isScheme('http') && !parsedUri.isScheme('https'))) { + if (!isAllowedWebUrl(uri)) { throw ArgumentError.value( uri, 'uri', diff --git a/lib/models/feed_state.dart b/lib/models/feed_state.dart index 777ef53..9182b30 100644 --- a/lib/models/feed_state.dart +++ b/lib/models/feed_state.dart @@ -15,6 +15,7 @@ class FeedState { this.isLoading = false, this.isLoadingMore = false, this.error, + this.loadMoreError, this.scrollPosition = 0.0, this.lastRefreshTime, }); @@ -39,9 +40,18 @@ class FeedState { /// Pagination (load more) in progress final bool isLoadingMore; - /// Error message if any + /// First-page error message, if any. + /// + /// Drives the full-screen error state. A pagination failure must never + /// land here — see [loadMoreError]. final String? error; + /// Pagination error message, if any. + /// + /// Drives the list's footer error. Kept separate from [error] so a + /// load-more hiccup cannot blank a feed that already has posts. + final String? loadMoreError; + /// Cached scroll position for this feed final double scrollPosition; @@ -50,9 +60,9 @@ class FeedState { /// Create a copy with modified fields (immutable updates) /// - /// Nullable fields (cursor, error, lastRefreshTime) use a sentinel pattern - /// to distinguish between "not provided" and "explicitly set to null". - /// Pass null explicitly to clear these fields. + /// Nullable fields (cursor, error, loadMoreError, lastRefreshTime) use a + /// sentinel pattern to distinguish between "not provided" and + /// "explicitly set to null". Pass null explicitly to clear these fields. FeedState copyWith({ List? posts, Object? cursor = _sentinel, @@ -60,6 +70,7 @@ class FeedState { bool? isLoading, bool? isLoadingMore, Object? error = _sentinel, + Object? loadMoreError = _sentinel, double? scrollPosition, Object? lastRefreshTime = _sentinel, }) { @@ -70,6 +81,10 @@ class FeedState { isLoading: isLoading ?? this.isLoading, isLoadingMore: isLoadingMore ?? this.isLoadingMore, error: error == _sentinel ? this.error : error as String?, + loadMoreError: + loadMoreError == _sentinel + ? this.loadMoreError + : loadMoreError as String?, scrollPosition: scrollPosition ?? this.scrollPosition, lastRefreshTime: lastRefreshTime == _sentinel diff --git a/lib/models/post.dart b/lib/models/post.dart index b4be70b..a5350fb 100644 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import '../constants/embed_types.dart'; +import '../utils/url_policy.dart'; import 'bluesky_post.dart'; import 'facet.dart'; @@ -635,25 +636,10 @@ class EmbedAspectRatio { /// The appview's hydration no-ops on `#view` types and the firehose consumer /// stores embeds verbatim, so a federated repo can publish a pre-stamped view /// carrying `file://`, `content://` or `javascript:` urls. The model is the -/// last line of defence, so the allowlist here is the same one -/// `UrlLauncher` enforces for outbound links: http and https only. -bool _isRenderableMediaUrl(String url) { - if (url.isEmpty) { - return false; - } - - final parsed = Uri.tryParse(url); - if (parsed == null) { - return false; - } - - // Uri lowercases the scheme while parsing, but compare case-insensitively - // anyway so 'HTTPS://…' cannot turn on a future refactor. The host check - // matters too: 'http:foo' and 'https:///path' carry an allowed scheme - // with no authority at all. - final scheme = parsed.scheme.toLowerCase(); - return (scheme == 'http' || scheme == 'https') && parsed.host.isNotEmpty; -} +/// last line of defence, so it applies the app-wide web allowlist — +/// [isAllowedWebUrl], the same predicate `UrlLauncher` enforces for outbound +/// links: http(s) with a host. +bool _isRenderableMediaUrl(String url) => isAllowedWebUrl(url); /// Lexicon caps for a hydrated gallery: at most 8 images, alt text at most /// 10000 characters. @@ -806,11 +792,8 @@ class EmbedSource { ); } - // Validate URI scheme for security - final parsedUri = Uri.tryParse(uri); - if (parsedUri == null || - !parsedUri.hasScheme || - !['http', 'https'].contains(parsedUri.scheme.toLowerCase())) { + // Validate URI scheme and host for security + if (!isAllowedWebUrl(uri)) { throw FormatException( 'EmbedSource: URI has invalid or unsupported scheme: $uri', ); diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index bbd3e3b..feea811 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import '../models/comment.dart'; import '../models/feed_state.dart'; @@ -7,6 +10,7 @@ import '../models/user_profile.dart'; import '../services/api_exceptions.dart'; import '../services/comment_service.dart'; import '../services/coves_api_service.dart'; +import '../utils/cursor_pagination_controller.dart'; import 'auth_provider.dart'; import 'vote_provider.dart'; @@ -28,10 +32,51 @@ class UserProfileProvider with ChangeNotifier { _apiService = apiService, _commentService = commentService, _voteProvider = voteProvider { + // The two feeds are the same cursor-pagination state machine with + // different fetchers; the controllers own items/cursor/loading/errors + // and this provider projects them onto the FeedState / CommentsState + // the screens already read. + _postsController = CursorPaginationController( + fetchPage: _fetchPostsPage, + onPageLoaded: _hydratePostVotes, + errorMapper: _postsErrorMessage, + // Server-side cursor drift hands back overlapping pages; the list + // keys its rows by this same URI and asserts on duplicates. + idOf: (feedItem) => feedItem.post.uri, + onUnexpectedError: _reportUnexpected, + )..addListener(_syncPostsState); + + _commentsController = CursorPaginationController( + fetchPage: _fetchCommentsPage, + onPageLoaded: _hydrateCommentVotes, + errorMapper: _commentsErrorMessage, + idOf: (comment) => comment.uri, + onUnexpectedError: _reportUnexpected, + )..addListener(_syncCommentsState); + // Listen to auth state changes _authProvider.addListener(_onAuthChanged); } + late final CursorPaginationController _postsController; + late final CursorPaginationController _commentsController; + + /// Everything the pagination controllers swallow: fetch failures the UI + /// already reports, vote-hydration failures it does not, and failures of + /// superseded requests. + /// + /// Typed [ApiException]s are skipped — they are the expected, already + /// user-presentable failures (offline, 404, 401), and reporting them + /// would drown the useful signal. A vote-hydration failure is exactly the + /// kind of silent breakage that has shipped wrong vote state before, so + /// it must reach crash reporting. + void _reportUnexpected(Object error, StackTrace stackTrace) { + if (error is ApiException) { + return; + } + unawaited(Sentry.captureException(error, stackTrace: stackTrace)); + } + AuthProvider _authProvider; final VoteProvider? _voteProvider; final CommentService _commentService; @@ -53,10 +98,12 @@ class UserProfileProvider with ChangeNotifier { String? _profileError; String? _currentProfileDid; - // Posts feed state (reusing FeedState pattern) + // Posts feed state — a projection of _postsController, rebuilt whenever + // the controller notifies (reusing the FeedState pattern the screens read) FeedState _postsState = FeedState.initial(); + DateTime? _postsLastRefreshTime; - // Comments feed state + // Comments feed state — a projection of _commentsController CommentsState _commentsState = CommentsState.initial(); // LRU profile cache keyed by DID (max 50 entries) @@ -117,8 +164,7 @@ class UserProfileProvider with ChangeNotifier { _profileCache.clear(); _cacheAccessOrder.clear(); _profile = null; - _postsState = FeedState.initial(); - _commentsState = CommentsState.initial(); + _resetFeeds(); _currentProfileDid = null; notifyListeners(); } @@ -218,119 +264,103 @@ class UserProfileProvider with ChangeNotifier { notifyListeners(); return; } - if (_postsState.isLoading || _postsState.isLoadingMore) return; - final currentState = _postsState; - - try { - if (refresh) { - _postsState = currentState.copyWith(isLoading: true, error: null); - } else { - if (!currentState.hasMore) return; - _postsState = currentState.copyWith(isLoadingMore: true); - } - notifyListeners(); + if (!refresh) { + await _postsController.loadMore(); + return; + } - final response = await _apiService.getAuthorPosts( - actor: _currentProfileDid!, - cursor: refresh ? null : currentState.cursor, - ); + // Only a refresh that actually landed its own page counts as "fresh + // as of now": a failed one, or one a newer refresh superseded, must not + // move the timestamp. + final refreshed = await _postsController.refresh(); + if (refreshed) { + _postsLastRefreshTime = DateTime.now(); + // The controller already notified with the new page; this second + // sync exists only to project the timestamp stamped above (which the + // controller knows nothing about) onto _postsState. + _syncPostsState(); + } + } - final List newPosts; - if (refresh) { - newPosts = response.feed; - } else { - newPosts = [...currentState.posts, ...response.feed]; - } + /// Load more posts (pagination) + /// + /// Failures land on `postsState.loadMoreError`, never on + /// `postsState.error`: a pagination hiccup must not blank a profile that + /// already has posts on screen. While that error is showing the + /// controller refuses further pages — use [retryLoadMorePosts] for the + /// footer's Retry, otherwise the scroll trigger would re-fire the failing + /// request on every scroll tick. + Future loadMorePosts() async { + await loadPosts(refresh: false); + } - _postsState = currentState.copyWith( - posts: newPosts, - cursor: response.cursor, - hasMore: response.cursor != null, - error: null, - isLoading: false, - isLoadingMore: false, - lastRefreshTime: - refresh ? DateTime.now() : currentState.lastRefreshTime, - ); + /// The posts footer's Retry: clears the pagination error and tries again. + Future retryLoadMorePosts() => _postsController.retryLoadMore(); - // Apply viewer vote state so a liked post shows a lit heart even - // when the profile is its first surface this session. - if (_authProvider.isAuthenticated && _voteProvider != null) { - for (final feedItem in response.feed) { - final viewer = feedItem.post.viewer; - _voteProvider.applyServerVoteState( - postUri: feedItem.post.uri, - voteDirection: viewer?.vote, - voteUri: viewer?.voteUri, - ); - } - } + /// The comments footer's Retry. + Future retryLoadMoreComments() => _commentsController.retryLoadMore(); - if (kDebugMode) { - debugPrint('✅ Author posts loaded: ${newPosts.length} posts total'); - } - } on AuthenticationException { - _postsState = currentState.copyWith( - error: 'Please sign in to view posts', - isLoading: false, - isLoadingMore: false, - ); + Future> _fetchPostsPage(String? cursor) async { + final actor = _currentProfileDid; + if (actor == null) throw ApiException('No profile loaded'); - if (kDebugMode) { - debugPrint('❌ Auth required to load posts'); - } - } on NotFoundException { - // 404 means the actor doesn't exist (not "no posts") - // Empty posts are returned as an empty array, not 404 - _postsState = currentState.copyWith( - error: 'User not found', - isLoading: false, - isLoadingMore: false, - ); + final response = await _apiService.getAuthorPosts( + actor: actor, + cursor: cursor, + ); - if (kDebugMode) { - debugPrint('❌ Actor not found when loading posts'); - } - } on NetworkException catch (e) { - _postsState = currentState.copyWith( - error: 'Network error. Check your connection.', - isLoading: false, - isLoadingMore: false, - ); + if (kDebugMode) { + debugPrint('✅ Author posts page loaded: ${response.feed.length} posts'); + } - if (kDebugMode) { - debugPrint('❌ Network error loading posts: ${e.message}'); - } - } on ApiException catch (e) { - _postsState = currentState.copyWith( - error: e.message, - isLoading: false, - isLoadingMore: false, - ); + return CursorPage( + items: response.feed, + cursor: response.cursor, + ); + } - if (kDebugMode) { - debugPrint('❌ Failed to load author posts: ${e.message}'); - } - } on Exception catch (e) { - // Catch-all for other exceptions - _postsState = currentState.copyWith( - error: 'Failed to load posts. Please try again.', - isLoading: false, - isLoadingMore: false, + /// Apply viewer vote state so a liked post shows a lit heart even when + /// the profile is its first surface this session. + Future _hydratePostVotes(List newPosts) async { + final voteProvider = _voteProvider; + if (!_authProvider.isAuthenticated || voteProvider == null) return; + + for (final feedItem in newPosts) { + final viewer = feedItem.post.viewer; + voteProvider.applyServerVoteState( + postUri: feedItem.post.uri, + voteDirection: viewer?.vote, + voteUri: viewer?.voteUri, ); - - if (kDebugMode) { - debugPrint('❌ Unexpected error loading posts: $e'); - } } + } - notifyListeners(); + String _postsErrorMessage(Object error) { + // 404 means the actor doesn't exist (not "no posts") — an empty feed + // comes back as an empty array. + if (error is AuthenticationException) return 'Please sign in to view posts'; + if (error is NotFoundException) return 'User not found'; + if (error is NetworkException) { + return 'Network error. Check your connection.'; + } + if (error is ApiException) return error.message; + return 'Failed to load posts. Please try again.'; } - /// Load more posts (pagination) - Future loadMorePosts() async { - await loadPosts(refresh: false); + void _syncPostsState() { + _postsState = FeedState( + posts: _postsController.items, + cursor: _postsController.cursor, + hasMore: _postsController.hasMore, + isLoading: _postsController.isLoading, + isLoadingMore: _postsController.isLoadingMore, + error: _postsController.error, + loadMoreError: _postsController.loadMoreError, + scrollPosition: _postsState.scrollPosition, + lastRefreshTime: _postsLastRefreshTime, + ); + notifyListeners(); } /// Load comments by the current profile's author @@ -347,119 +377,85 @@ class UserProfileProvider with ChangeNotifier { notifyListeners(); return; } - if (_commentsState.isLoading || _commentsState.isLoadingMore) return; - final currentState = _commentsState; + if (refresh) { + await _commentsController.refresh(); + } else { + await _commentsController.loadMore(); + } + } - try { - if (refresh) { - _commentsState = currentState.copyWith(isLoading: true, error: null); - } else { - if (!currentState.hasMore) return; - _commentsState = currentState.copyWith(isLoadingMore: true); - } - notifyListeners(); + /// Load more comments (pagination) + /// + /// Failures land on `commentsState.loadMoreError`, never on + /// `commentsState.error`. + Future loadMoreComments() async { + await loadComments(refresh: false); + } - final response = await _apiService.getActorComments( - actor: _currentProfileDid!, - cursor: refresh ? null : currentState.cursor, - ); + Future> _fetchCommentsPage(String? cursor) async { + final actor = _currentProfileDid; + if (actor == null) throw ApiException('No profile loaded'); - final List newComments; - if (refresh) { - newComments = response.comments; - } else { - newComments = [...currentState.comments, ...response.comments]; - } + final response = await _apiService.getActorComments( + actor: actor, + cursor: cursor, + ); - _commentsState = currentState.copyWith( - comments: newComments, - cursor: response.cursor, - hasMore: response.cursor != null, - error: null, - isLoading: false, - isLoadingMore: false, + if (kDebugMode) { + debugPrint( + '✅ Author comments page loaded: ${response.comments.length} comments', ); + } - // Apply viewer vote state from the comments response. Safe on both - // refresh and pagination: the provider keeps an optimistic vote the - // appview has not indexed yet instead of adopting a stale snapshot. - if (_authProvider.isAuthenticated && _voteProvider != null) { - response.comments.forEach(_applyCommentVoteState); - } else if (_authProvider.isAuthenticated && _voteProvider == null) { - if (kDebugMode) { - debugPrint( - '⚠️ VoteProvider is null - ' - 'cannot apply comment vote states', - ); - } - } + return CursorPage( + items: response.comments, + cursor: response.cursor, + ); + } + + /// Apply viewer vote state from the comments response. Safe on both + /// refresh and pagination: the vote provider keeps an optimistic vote the + /// appview has not indexed yet instead of adopting a stale snapshot. + Future _hydrateCommentVotes(List newComments) async { + if (!_authProvider.isAuthenticated) return; + if (_voteProvider == null) { if (kDebugMode) { debugPrint( - '✅ Author comments loaded: ${newComments.length} comments total', + '⚠️ VoteProvider is null - cannot apply comment vote states', ); } - } on AuthenticationException { - _commentsState = currentState.copyWith( - error: 'Please sign in to view comments', - isLoading: false, - isLoadingMore: false, - ); - - if (kDebugMode) { - debugPrint('❌ Auth required to load comments'); - } - } on NotFoundException { - // 404 means the actor doesn't exist (not "no comments") - // Empty comments are returned as an empty array, not 404 - _commentsState = currentState.copyWith( - error: 'User not found', - isLoading: false, - isLoadingMore: false, - ); - - if (kDebugMode) { - debugPrint('❌ Actor not found when loading comments'); - } - } on NetworkException catch (e) { - _commentsState = currentState.copyWith( - error: 'Network error. Check your connection.', - isLoading: false, - isLoadingMore: false, - ); - - if (kDebugMode) { - debugPrint('❌ Network error loading comments: ${e.message}'); - } - } on ApiException catch (e) { - _commentsState = currentState.copyWith( - error: e.message, - isLoading: false, - isLoadingMore: false, - ); + return; + } - if (kDebugMode) { - debugPrint('❌ Failed to load author comments: ${e.message}'); - } - } on Exception catch (e) { - _commentsState = currentState.copyWith( - error: 'Failed to load comments. Please try again.', - isLoading: false, - isLoadingMore: false, - ); + newComments.forEach(_applyCommentVoteState); + } - if (kDebugMode) { - debugPrint('❌ Unexpected error loading comments: $e'); - } + String _commentsErrorMessage(Object error) { + // 404 means the actor doesn't exist (not "no comments"). + if (error is AuthenticationException) { + return 'Please sign in to view comments'; } - - notifyListeners(); + if (error is NotFoundException) return 'User not found'; + if (error is NetworkException) { + return 'Network error. Check your connection.'; + } + if (error is ApiException) return error.message; + return 'Failed to load comments. Please try again.'; } - /// Load more comments (pagination) - Future loadMoreComments() async { - await loadComments(refresh: false); + void _syncCommentsState() { + _commentsState = CommentsState( + comments: _commentsController.items, + cursor: _commentsController.cursor, + hasMore: _commentsController.hasMore, + isLoading: _commentsController.isLoading, + isLoadingMore: _commentsController.isLoadingMore, + error: _commentsController.error, + loadMoreError: _commentsController.loadMoreError, + ); + notifyListeners(); } /// Delete a comment from the user's profile comments @@ -481,14 +477,9 @@ class UserProfileProvider with ChangeNotifier { try { await _commentService.deleteComment(uri: commentUri); - // Remove the comment from local state - final updatedComments = - _commentsState.comments - .where((c) => c.uri != commentUri) - .toList(); - - _commentsState = _commentsState.copyWith(comments: updatedComments); - notifyListeners(); + // Remove the comment from local state (the controller notifies, which + // re-projects _commentsState) + _commentsController.removeWhere((c) => c.uri == commentUri); if (kDebugMode) { debugPrint('✅ Comment deleted from profile'); @@ -527,13 +518,22 @@ class UserProfileProvider with ChangeNotifier { void clearProfile() { _profile = null; _currentProfileDid = null; - _postsState = FeedState.initial(); - _commentsState = CommentsState.initial(); + _resetFeeds(); _profileError = null; _isLoadingProfile = false; notifyListeners(); } + /// Drop both feeds back to their pre-load state, orphaning any in-flight + /// page so it cannot land on the next profile. + void _resetFeeds() { + _postsController.reset(); + _commentsController.reset(); + _postsLastRefreshTime = null; + _postsState = FeedState.initial(); + _commentsState = CommentsState.initial(); + } + /// Set an error message directly (for cases like missing actor) void setError(String message) { _profileError = message; @@ -625,6 +625,8 @@ class UserProfileProvider with ChangeNotifier { @override void dispose() { _authProvider.removeListener(_onAuthChanged); + _postsController.dispose(); + _commentsController.dispose(); super.dispose(); } } diff --git a/lib/screens/community/community_feed_screen.dart b/lib/screens/community/community_feed_screen.dart index af7a2c4..15d74f6 100644 --- a/lib/screens/community/community_feed_screen.dart +++ b/lib/screens/community/community_feed_screen.dart @@ -1,10 +1,11 @@ +import 'dart:async'; import 'dart:ui'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import '../../constants/app_colors.dart'; import '../../utils/responsive_utils.dart'; import '../../models/community.dart'; @@ -12,11 +13,16 @@ import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../providers/community_subscription_provider.dart'; import '../../providers/vote_provider.dart'; +import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; +import '../../utils/cursor_pagination_controller.dart'; import '../../utils/display_utils.dart'; import '../../utils/error_messages.dart'; +import '../../utils/pagination_scroll_listener.dart'; +import '../../widgets/community_avatar.dart'; import '../../widgets/community_header.dart'; import '../../widgets/loading_error_states.dart'; +import '../../widgets/paginated_sliver_list.dart'; import '../../widgets/post_card.dart'; import '../../widgets/share_button.dart'; @@ -61,24 +67,37 @@ class _CommunityFeedScreenState extends State { String? _communityError; bool _communityIsAuthError = false; - // Feed state - List _posts = []; - bool _isLoadingFeed = false; - bool _isLoadingMore = false; - String? _feedError; - String? _loadMoreError; - String? _cursor; - bool _hasMore = true; + // Feed state — items, cursor, loading flags and both error channels live + // in the shared controller + late final CursorPaginationController _feedController; + late final PaginationScrollListener _paginationListener; // Time for relative timestamps DateTime _currentTime = DateTime.now(); + // One pending post-frame "does the content fill the viewport?" check. + bool _viewportFillCheckScheduled = false; + @override void initState() { super.initState(); _apiService = context.read(); _community = widget.community; - _scrollController.addListener(_onScroll); + + _feedController = CursorPaginationController( + fetchPage: _fetchFeedPage, + onPageLoaded: _syncViewerStates, + errorMapper: ErrorMessage.loadFeed, + // Cursor drift hands back overlapping pages; the list keys its rows + // by this URI and asserts on duplicates. + idOf: (post) => post.post.uri, + onUnexpectedError: _reportUnexpected, + )..addListener(_onFeedChanged); + + _paginationListener = PaginationScrollListener( + controller: _scrollController, + onLoadMore: _feedController.loadMore, + )..attach(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -89,15 +108,43 @@ class _CommunityFeedScreenState extends State { @override void dispose() { + _paginationListener.dispose(); + _feedController.dispose(); _scrollController.dispose(); super.dispose(); } - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200) { - _loadMore(); + /// The controller owns the feed state; rebuild when it changes. + void _onFeedChanged() { + if (!mounted) { + return; } + setState(() {}); + _scheduleViewportFillCheck(); + } + + /// Keep loading while the loaded posts do not fill the viewport. + /// + /// [PaginationScrollListener] only fires on scroll events, so a first + /// page shorter than the screen leaves nothing to scroll and pagination + /// stalls (the profile screen's old build-phase trigger did not have this + /// hole). Asked once per landed page, after layout. + void _scheduleViewportFillCheck() { + if (_viewportFillCheckScheduled || + _feedController.isLoading || + _feedController.isLoadingMore || + _feedController.loadMoreError != null || + !_feedController.hasMore) { + return; + } + + _viewportFillCheckScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _viewportFillCheckScheduled = false; + if (mounted) { + _paginationListener.checkNow(); + } + }); } void _onTabChanged(int index) { @@ -111,14 +158,17 @@ class _CommunityFeedScreenState extends State { setState(() { _feedSort = sort; }); - _loadFeed(refresh: true); + // Whatever is in flight for the previous sort — a first page or an + // append — is superseded by this refresh and discarded when it lands, + // so the new label can never sit above the old sort's posts. + _loadFeed(); } Future _initializeAndLoad() async { if (_community == null) { await _loadCommunity(); } - await _loadFeed(refresh: true); + await _loadFeed(); } Future _loadCommunity() async { @@ -167,98 +217,51 @@ class _CommunityFeedScreenState extends State { } } - Future _loadFeed({bool refresh = false}) async { - if (_isLoadingFeed) return; - - setState(() { - _isLoadingFeed = true; - if (refresh) { - _feedError = null; - _cursor = null; - _hasMore = true; - } - }); - - try { - final response = await _apiService.getCommunityFeed( - community: widget.identifier, - sort: _feedSort, - cursor: refresh ? null : _cursor, - ); - - if (mounted) { - setState(() { - _currentTime = DateTime.now(); - if (refresh) { - _posts = response.feed; - } else { - _posts = [..._posts, ...response.feed]; - } - _cursor = response.cursor; - _hasMore = response.cursor != null; - _isLoadingFeed = false; - }); - - _syncViewerStates(response.feed); - } - } catch (e) { - if (kDebugMode) { - debugPrint('Error loading community feed: $e'); - } - if (mounted) { - setState(() { - _feedError = ErrorMessage.loadFeed(e); - _isLoadingFeed = false; - }); - } + /// Everything the feed controller swallows. + /// + /// Typed [ApiException]s are skipped: they are the expected, + /// already-on-screen failures. What matters here is the rest — above all + /// a viewer-state hydration failure, which silently leaves votes and + /// subscriptions wrong in the UI. + void _reportUnexpected(Object error, StackTrace stackTrace) { + if (error is ApiException) { + return; } + unawaited(Sentry.captureException(error, stackTrace: stackTrace)); } - Future _loadMore() async { - if (_isLoadingMore || !_hasMore || _isLoadingFeed) return; - - setState(() { - _isLoadingMore = true; - _loadMoreError = null; - }); - - try { - final response = await _apiService.getCommunityFeed( - community: widget.identifier, - sort: _feedSort, - cursor: _cursor, - ); - - if (mounted) { - setState(() { - _posts = [..._posts, ...response.feed]; - _cursor = response.cursor; - _hasMore = response.cursor != null; - _isLoadingMore = false; - }); - - _syncViewerStates(response.feed); - } - } catch (e) { - if (kDebugMode) { - debugPrint('Error loading more posts: $e'); - } - if (mounted) { - setState(() { - _isLoadingMore = false; - _loadMoreError = ErrorMessage.loadFeed(e); - }); - } + /// Reload the feed from the top (also the initial load). + /// + /// The controller clears both error channels and supersedes any in-flight + /// request, so a stale load-more error or a page of the previous sort can + /// never survive into the new feed. + Future _loadFeed() async { + await _feedController.refresh(); + if (mounted) { + setState(() { + _currentTime = DateTime.now(); + }); } } - void _clearLoadMoreError() { - setState(() { - _loadMoreError = null; - }); + Future> _fetchFeedPage(String? cursor) async { + final response = await _apiService.getCommunityFeed( + community: widget.identifier, + sort: _feedSort, + cursor: cursor, + ); + + return CursorPage( + items: response.feed, + cursor: response.cursor, + ); } - void _syncViewerStates(List posts) { + Future _syncViewerStates(List posts) async { + if (!mounted) { + return; + } + final authProvider = context.read(); if (!authProvider.isAuthenticated) return; @@ -285,7 +288,7 @@ class _CommunityFeedScreenState extends State { Future _onRefresh() async { await _loadCommunity(); - await _loadFeed(refresh: true); + await _loadFeed(); } @override @@ -408,28 +411,11 @@ class _CommunityFeedScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (_community?.avatar != null && - _community!.avatar!.isNotEmpty) - ClipOval( - child: CachedNetworkImage( - imageUrl: _community!.avatar!, - width: 28, - height: 28, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - errorWidget: (context, url, error) { - if (kDebugMode) { - debugPrint( - 'Error loading collapsed avatar: $error', - ); - } - return _buildCollapsedFallbackAvatar(); - }, - ), - ) - else - _buildCollapsedFallbackAvatar(), + CommunityAvatar( + name: _community?.name ?? '', + avatarUrl: _community?.avatar, + size: 28, + ), const SizedBox(width: 10), Flexible( child: Column( @@ -557,30 +543,6 @@ class _CommunityFeedScreenState extends State { return 'coves.social'; } - Widget _buildCollapsedFallbackAvatar() { - final name = _community?.name ?? ''; - final bgColor = DisplayUtils.getFallbackColor(name); - - return Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: bgColor, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : 'C', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ); - } - Widget _buildSubscribeButton() { final isAuthenticated = context.watch().isAuthenticated; if (!isAuthenticated || _community == null) { @@ -684,8 +646,10 @@ class _CommunityFeedScreenState extends State { } Widget _buildPostsList() { + final posts = _feedController.items; + // Loading state - if (_isLoadingFeed && _posts.isEmpty) { + if (_feedController.isLoading && posts.isEmpty) { return const SliverFillRemaining( child: Center( child: CircularProgressIndicator(color: AppColors.primary), @@ -693,75 +657,51 @@ class _CommunityFeedScreenState extends State { ); } - // Error state - if (_feedError != null && _posts.isEmpty) { + // Full-screen error only while there is nothing to read. With posts on + // screen the same failure (a pull-to-refresh that failed) goes to the + // footer instead — blanking readable content would be worse, and + // showing nothing at all was the old bug. + final feedError = _feedController.error; + if (feedError != null && posts.isEmpty) { return SliverFillRemaining( child: Center( - child: InlineError( - message: _feedError!, - onRetry: () => _loadFeed(refresh: true), - ), + child: InlineError(message: feedError, onRetry: _loadFeed), ), ); } - // Empty state - if (_posts.isEmpty && !_isLoadingFeed) { - return SliverFillRemaining( - child: _buildEmptyPostsState(), - ); - } + return PaginatedSliverList( + items: posts, + isLoadingMore: _feedController.isLoadingMore, + hasMore: _feedController.hasMore, + loadMoreError: _feedController.loadMoreError, + refreshError: posts.isEmpty ? null : feedError, + onRetryRefresh: _loadFeed, + onRetryLoadMore: _feedController.retryLoadMore, + idOf: (post) => post.post.uri, + footerKey: const ValueKey('community_feed_footer'), + endOfFeedWidget: _buildEndOfFeed(), + emptyWidget: _buildEmptyPostsState(), + itemBuilder: (context, post, index) { + final postCard = PostCard( + post: post, + currentTime: _currentTime, + showHeader: true, + ); - // Posts list with loading indicator - final showLoadingSlot = _isLoadingMore || _loadMoreError != null; - - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == _posts.length) { - if (_isLoadingMore) { - return const InlineLoading(); - } - if (_loadMoreError != null) { - return InlineError( - message: _loadMoreError!, - onRetry: () { - _clearLoadMoreError(); - _loadMore(); - }, - ); - } - if (!_hasMore && _posts.isNotEmpty) { - return _buildEndOfFeed(); - } - return const SizedBox(height: 80); - } - - final post = _posts[index]; - final postCard = RepaintBoundary( - key: ValueKey(post.post.uri), - child: PostCard( - post: post, - currentTime: _currentTime, - showHeader: true, + // Constrain width on tablets for better readability + if (ResponsiveUtils.isTablet(context)) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveUtils.maxContentWidth, + ), + child: postCard, ), ); - - // Constrain width on tablets for better readability - if (ResponsiveUtils.isTablet(context)) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: ResponsiveUtils.maxContentWidth, - ), - child: postCard, - ), - ); - } - return postCard; - }, - childCount: _posts.length + (showLoadingSlot || !_hasMore ? 1 : 0), - ), + } + return postCard; + }, ); } diff --git a/lib/screens/compose/community_picker_screen.dart b/lib/screens/compose/community_picker_screen.dart index 812812d..57c1001 100644 --- a/lib/screens/compose/community_picker_screen.dart +++ b/lib/screens/compose/community_picker_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +7,8 @@ import '../../constants/app_colors.dart'; import '../../models/community.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; +import '../../utils/display_utils.dart'; +import '../../widgets/community_avatar.dart'; /// Community Picker Screen /// @@ -368,63 +369,11 @@ class _CommunityPickerScreenState extends State { ); } - Widget _buildCommunityAvatar(CommunityView community) { - final fallbackChild = CircleAvatar( - radius: 20, - backgroundColor: AppColors.backgroundSecondary, - foregroundColor: Colors.white, - child: Text( - community.name.isNotEmpty ? community.name[0].toUpperCase() : '?', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ); - - if (community.avatar == null) { - return fallbackChild; - } - - return CachedNetworkImage( - imageUrl: community.avatar!, - imageBuilder: (context, imageProvider) => CircleAvatar( - radius: 20, - backgroundColor: AppColors.backgroundSecondary, - backgroundImage: imageProvider, - ), - placeholder: (context, url) => CircleAvatar( - radius: 20, - backgroundColor: AppColors.backgroundSecondary, - child: const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ), - ), - errorWidget: (context, url, error) => fallbackChild, - ); - } - Widget _buildCommunityTile(CommunityView community) { - // Format member count - String formatCount(int? count) { - if (count == null) { - return '0'; - } - if (count >= 1000000) { - return '${(count / 1000000).toStringAsFixed(1)}M'; - } else if (count >= 1000) { - return '${(count / 1000).toStringAsFixed(1)}K'; - } - return count.toString(); - } - - final memberCount = formatCount(community.memberCount); - final subscriberCount = formatCount(community.subscriberCount); + final memberCount = DisplayUtils.formatCount(community.memberCount ?? 0); + final subscriberCount = DisplayUtils.formatCount( + community.subscriberCount ?? 0, + ); // Build description line var descriptionLine = ''; @@ -463,7 +412,12 @@ class _CommunityPickerScreenState extends State { child: Row( children: [ // Avatar - _buildCommunityAvatar(community), + CommunityAvatar( + name: community.name, + avatarUrl: community.avatar, + size: 40, + showLoadingIndicator: true, + ), const SizedBox(width: 12), // Community info diff --git a/lib/screens/home/communities_admin_panel.dart b/lib/screens/home/communities_admin_panel.dart index ed0d003..414f531 100644 --- a/lib/screens/home/communities_admin_panel.dart +++ b/lib/screens/home/communities_admin_panel.dart @@ -1,6 +1,5 @@ import 'dart:developer' as developer; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,6 +11,7 @@ import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; import '../../utils/image_crop_utils.dart'; import '../../utils/image_picker_utils.dart'; +import '../../widgets/community_avatar.dart'; import '../../widgets/image_source_picker.dart'; /// Admin handles that can create communities @@ -634,31 +634,16 @@ class _CommunitiesAdminPanelState extends State { borderRadius: BorderRadius.circular(50), border: Border.all(color: AppColors.border, width: 2), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(50), - child: _selectedCommunity!.avatar != null - ? CachedNetworkImage( - imageUrl: '${_selectedCommunity!.avatar!}', - width: 100, - height: 100, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => Container( - color: AppColors.backgroundSecondary, - ), - errorWidget: (context, url, error) => - const Icon( - Icons.workspaces_outlined, - size: 40, - color: AppColors.primary, - ), - ) - : const Icon( - Icons.workspaces_outlined, - size: 40, - color: AppColors.primary, - ), + child: CommunityAvatar( + name: _selectedCommunity!.name, + avatarUrl: _selectedCommunity!.avatar, + size: 100, + fallbackColor: AppColors.backgroundSecondary, + fallbackIcon: const Icon( + Icons.workspaces_outlined, + size: 40, + color: AppColors.primary, + ), ), ), const SizedBox(height: 8), @@ -809,31 +794,16 @@ class _CommunitiesAdminPanelState extends State { borderRadius: BorderRadius.circular(60), border: Border.all(color: AppColors.border, width: 2), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(60), - child: _selectedCommunity!.avatar != null - ? CachedNetworkImage( - imageUrl: '${_selectedCommunity!.avatar!}', - width: 120, - height: 120, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => Container( - color: AppColors.backgroundSecondary, - ), - errorWidget: (context, url, error) => - const Icon( - Icons.workspaces_outlined, - size: 48, - color: AppColors.primary, - ), - ) - : const Icon( - Icons.workspaces_outlined, - size: 48, - color: AppColors.primary, - ), + child: CommunityAvatar( + name: _selectedCommunity!.name, + avatarUrl: _selectedCommunity!.avatar, + size: 120, + fallbackColor: AppColors.backgroundSecondary, + fallbackIcon: const Icon( + Icons.workspaces_outlined, + size: 48, + color: AppColors.primary, + ), ), ), const SizedBox(height: 8), @@ -903,38 +873,15 @@ class _CommunitiesAdminPanelState extends State { ), child: Row( children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.circular(20), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: community.avatar != null - ? CachedNetworkImage( - imageUrl: '${community.avatar!}', - width: 40, - height: 40, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => Container( - color: AppColors.backgroundSecondary, - ), - errorWidget: (context, url, error) => - const Icon( - Icons.workspaces_outlined, - size: 20, - color: AppColors.primary, - ), - ) - : const Icon( - Icons.workspaces_outlined, - size: 20, - color: AppColors.primary, - ), + CommunityAvatar( + name: community.name, + avatarUrl: community.avatar, + size: 40, + fallbackColor: AppColors.background, + fallbackIcon: const Icon( + Icons.workspaces_outlined, + size: 20, + color: AppColors.primary, ), ), const SizedBox(width: 12), diff --git a/lib/screens/home/communities_see_all_screen.dart b/lib/screens/home/communities_see_all_screen.dart index fae7378..2dd46a5 100644 --- a/lib/screens/home/communities_see_all_screen.dart +++ b/lib/screens/home/communities_see_all_screen.dart @@ -11,7 +11,10 @@ import '../../models/community.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; import '../../utils/community_search_utils.dart'; +import '../../utils/cursor_pagination_controller.dart'; +import '../../utils/pagination_scroll_listener.dart'; import '../../widgets/community_list_tile.dart'; +import '../../widgets/paginated_sliver_list.dart'; /// Full paginated list of communities for a given sort/filter. /// @@ -38,14 +41,17 @@ class _CommunitiesSeeAllScreenState extends State { final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); - List _communities = []; + /// Loaded pages, cursor, loading flags and both error channels. + late final CursorPaginationController _controller; + late final PaginationScrollListener _paginationListener; + + /// The loaded communities narrowed by the search box. Search is + /// client-side over the pages loaded so far — it does not query the API. List _filteredCommunities = []; - bool _isLoading = false; - bool _isLoadingMore = false; - String? _error; - String? _cursor; - bool _hasMore = true; Timer? _searchDebounce; + + // One pending post-frame "does the content fill the viewport?" check. + bool _viewportFillCheckScheduled = false; // Shared app-wide API client (owned by main.dart) — do not dispose here late final CovesApiService _apiService; @@ -53,16 +59,32 @@ class _CommunitiesSeeAllScreenState extends State { void initState() { super.initState(); _apiService = context.read(); + + _controller = CursorPaginationController( + fetchPage: _fetchCommunitiesPage, + errorMapper: _errorMessage, + // Cursor drift hands back overlapping pages; the list keys its rows + // by this DID and asserts on duplicates. + idOf: (community) => community.did, + onUnexpectedError: _reportUnexpected, + )..addListener(_onCommunitiesChanged); + + _paginationListener = PaginationScrollListener( + controller: _scrollController, + onLoadMore: _controller.loadMore, + )..attach(); + _searchController.addListener(_onSearchChanged); - _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - _loadCommunities(); + _controller.refresh(); }); } @override void dispose() { _searchController.dispose(); + _paginationListener.dispose(); + _controller.dispose(); _scrollController.dispose(); _searchDebounce?.cancel(); super.dispose(); @@ -76,113 +98,88 @@ class _CommunitiesSeeAllScreenState extends State { ); } + void _onCommunitiesChanged() { + if (!mounted) { + return; + } + _filterCommunities(); + _scheduleViewportFillCheck(); + } + + /// Keep loading while the loaded communities do not fill the viewport. + /// + /// [PaginationScrollListener] only fires on scroll events, so a first + /// page shorter than the screen leaves nothing to scroll and pagination + /// stalls. Skipped while a search is active: an almost-empty list is then + /// a filter artifact, not a short page, and chasing it would pull the + /// whole directory down 50 rows at a time. + void _scheduleViewportFillCheck() { + if (_viewportFillCheckScheduled || + _controller.isLoading || + _controller.isLoadingMore || + _controller.loadMoreError != null || + !_controller.hasMore || + _searchController.text.trim().isNotEmpty) { + return; + } + + _viewportFillCheckScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _viewportFillCheckScheduled = false; + if (mounted) { + _paginationListener.checkNow(); + } + }); + } + void _filterCommunities() { final query = _searchController.text.trim().toLowerCase(); setState(() { _filteredCommunities = CommunitySearchUtils.filterByQuery( - _communities, + _controller.items, query, ); }); } - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { - if (!_isLoadingMore && _hasMore && !_isLoading) { - _loadMoreCommunities(); - } - } - } - - Future _loadCommunities() async { - if (_isLoading) return; - - setState(() { - _isLoading = true; - _error = null; - }); + Future> _fetchCommunitiesPage( + String? cursor, + ) async { + final response = await _apiService.listCommunities( + limit: 50, + cursor: cursor, + sort: widget.sort, + subscribed: widget.subscribed, + ); - try { - final response = await _apiService.listCommunities( - limit: 50, - sort: widget.sort, - subscribed: widget.subscribed, - ); + return CursorPage( + items: response.communities, + cursor: response.cursor, + ); + } - if (mounted) { - setState(() { - _communities = response.communities; - _filteredCommunities = response.communities; - _cursor = response.cursor; - _hasMore = response.cursor != null && response.cursor!.isNotEmpty; - _isLoading = false; - }); - } - } on ApiException catch (e) { - if (mounted) { - setState(() { - _error = e.message; - _isLoading = false; - }); - } - } on Exception catch (e, stackTrace) { - if (kDebugMode) { - debugPrint('Failed to load communities: $e'); - } - unawaited(Sentry.captureException(e, stackTrace: stackTrace)); - if (mounted) { - setState(() { - _error = 'Failed to load communities. Pull down to retry.'; - _isLoading = false; - }); - } + /// Everything the controller swallows. + /// + /// Typed [ApiException]s are skipped — already-typed, user-presentable + /// failures, reported to the user by the error states below. This is the + /// single reporting point now: the controller catches fetch failures on + /// the caller's behalf, so the fetcher no longer captures its own. + void _reportUnexpected(Object error, StackTrace stackTrace) { + if (error is ApiException) { + return; + } + if (kDebugMode) { + debugPrint('Failed to load communities: $error'); } + unawaited(Sentry.captureException(error, stackTrace: stackTrace)); } - Future _loadMoreCommunities() async { - if (_isLoadingMore || !_hasMore || _cursor == null) return; - - setState(() { - _isLoadingMore = true; - }); - - try { - final response = await _apiService.listCommunities( - limit: 50, - cursor: _cursor, - sort: widget.sort, - subscribed: widget.subscribed, - ); - - if (mounted) { - setState(() { - _communities.addAll(response.communities); - _cursor = response.cursor; - _hasMore = response.cursor != null && response.cursor!.isNotEmpty; - _isLoadingMore = false; - }); - _filterCommunities(); - } - } on Exception catch (e, stackTrace) { - if (kDebugMode) { - debugPrint('Failed to load more communities: $e'); - } - unawaited(Sentry.captureException(e, stackTrace: stackTrace)); - if (mounted) { - setState(() { - _isLoadingMore = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Failed to load more communities. Try scrolling again.', - ), - ), - ); - } + String _errorMessage(Object error) { + if (error is ApiException) { + return error.message; } + return 'Failed to load communities. Pull down to retry.'; } @override @@ -241,23 +238,21 @@ class _CommunitiesSeeAllScreenState extends State { } Future _refreshCommunities() async { - setState(() { - _cursor = null; - _hasMore = true; - _communities = []; - _filteredCommunities = []; - }); - await _loadCommunities(); + await _controller.refresh(); } Widget _buildBody() { - if (_isLoading) { + final communities = _controller.items; + + if (_controller.isLoading && communities.isEmpty) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } - if (_error != null) { + // First-page failure — pagination failures show in the list footer. + final error = _controller.error; + if (error != null && communities.isEmpty) { return RefreshIndicator( onRefresh: _refreshCommunities, color: AppColors.primary, @@ -279,7 +274,7 @@ class _CommunitiesSeeAllScreenState extends State { ), const SizedBox(height: 16), Text( - _error!, + error, style: const TextStyle( color: AppColors.textSecondary, fontSize: 16, @@ -288,7 +283,7 @@ class _CommunitiesSeeAllScreenState extends State { ), const SizedBox(height: 24), ElevatedButton( - onPressed: _loadCommunities, + onPressed: _controller.refresh, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, foregroundColor: AppColors.textPrimary, @@ -312,70 +307,69 @@ class _CommunitiesSeeAllScreenState extends State { ); } - if (_filteredCommunities.isEmpty) { - return RefreshIndicator( - onRefresh: _refreshCommunities, - color: AppColors.primary, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.search_off, - size: 48, - color: AppColors.textMuted, - ), - const SizedBox(height: 16), - Text( - _searchController.text.trim().isEmpty - ? 'No communities found' - : 'No communities match your search', - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ], - ), - ); - } - return RefreshIndicator( onRefresh: _refreshCommunities, color: AppColors.primary, - child: ListView.builder( + child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - itemCount: _filteredCommunities.length + (_isLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index == _filteredCommunities.length) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator(color: AppColors.primary), + slivers: [ + PaginatedSliverList( + items: _filteredCommunities, + isLoadingMore: _controller.isLoadingMore, + hasMore: _controller.hasMore, + loadMoreError: _controller.loadMoreError, + // A pull-to-refresh that fails with rows on screen: the + // full-screen error above is empty-list-only, so without this + // the failure would be invisible. + refreshError: communities.isEmpty ? null : error, + onRetryRefresh: _refreshCommunities, + onRetryLoadMore: _controller.retryLoadMore, + idOf: (community) => community.did, + footerKey: const ValueKey('communities_see_all_footer'), + emptyWidget: _buildEmptyState(), + endOfFeedWidget: const Padding( + padding: EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: Text( + "That's every community", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), ), - ); - } + ), + itemBuilder: (context, community, index) => CommunityListTile( + community: community, + onTap: () => context.push('/community/${community.did}'), + ), + ), + ], + ), + ); + } - final community = _filteredCommunities[index]; - return CommunityListTile( - community: community, - onTap: () => context.push('/community/${community.did}'), - ); - }, + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_off, size: 48, color: AppColors.textMuted), + const SizedBox(height: 16), + Text( + _searchController.text.trim().isEmpty + ? 'No communities found' + : 'No communities match your search', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ], + ), ), ); } diff --git a/lib/screens/home/create_post_screen.dart b/lib/screens/home/create_post_screen.dart index b70f9c2..ab698d3 100644 --- a/lib/screens/home/create_post_screen.dart +++ b/lib/screens/home/create_post_screen.dart @@ -9,6 +9,7 @@ import '../../providers/auth_provider.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; import '../../utils/facet_detector.dart'; +import '../../utils/url_policy.dart'; import '../compose/community_picker_screen.dart'; import 'post_detail_screen.dart'; @@ -179,8 +180,7 @@ class _CreatePostScreenState extends State final url = _urlController.text.trim(); if (url.isNotEmpty) { // Validate URL - final uri = Uri.tryParse(url); - if (uri == null || !uri.hasScheme || (!uri.scheme.startsWith('http'))) { + if (!isAllowedWebUrl(url)) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/home/edit_profile_screen.dart b/lib/screens/home/edit_profile_screen.dart index 25cdbf9..570e78f 100644 --- a/lib/screens/home/edit_profile_screen.dart +++ b/lib/screens/home/edit_profile_screen.dart @@ -10,6 +10,7 @@ import '../../services/api_exceptions.dart'; import '../../utils/image_crop_utils.dart'; import '../../utils/image_picker_utils.dart'; import '../../widgets/image_source_picker.dart'; +import '../../widgets/user_avatar.dart'; /// Content limits matching backend lexicon const int kDisplayNameMaxLength = 64; @@ -431,18 +432,17 @@ class _EditProfileScreenState extends State { _selectedAvatar!.file, fit: BoxFit.cover, ) - : (widget.profile.avatar != null) - ? CachedNetworkImage( - imageUrl: widget.profile.avatar!, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - errorWidget: (context, url, error) => - _buildFallbackAvatar( - avatarSize - 8, - ), - ) - : _buildFallbackAvatar(avatarSize - 8), + : UserAvatar( + name: widget.profile.handle ?? '', + avatarUrl: widget.profile.avatar, + size: avatarSize - 8, + fallbackColor: AppColors.primary, + fallbackIcon: const Icon( + Icons.person, + size: (avatarSize - 8) * 0.5, + color: Colors.white, + ), + ), ), // Edit overlay Positioned.fill( @@ -495,19 +495,6 @@ class _EditProfileScreenState extends State { ); } - Widget _buildFallbackAvatar(double size) { - return Container( - width: size, - height: size, - color: AppColors.primary, - child: Icon( - Icons.person, - size: size * 0.5, - color: Colors.white, - ), - ); - } - Widget _buildTextField({ required TextEditingController controller, required String label, diff --git a/lib/screens/home/post_detail_screen.dart b/lib/screens/home/post_detail_screen.dart index 93ae207..b76163e 100644 --- a/lib/screens/home/post_detail_screen.dart +++ b/lib/screens/home/post_detail_screen.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,10 +11,10 @@ import '../../providers/auth_provider.dart'; import '../../providers/comments_provider.dart'; import '../../providers/vote_provider.dart'; import '../../services/comments_provider_cache.dart'; -import '../../utils/display_utils.dart'; import '../../utils/error_messages.dart'; import '../../widgets/comment_thread.dart'; import '../../widgets/comments_header.dart'; +import '../../widgets/community_avatar.dart'; import '../../widgets/share_button.dart'; import '../../widgets/detailed_post_view.dart'; import '../../widgets/loading_error_states.dart'; @@ -367,7 +366,11 @@ class _PostDetailScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ // Community avatar - _buildCommunityAvatar(community), + CommunityAvatar( + name: community.name, + avatarUrl: community.avatar, + size: 28, + ), const SizedBox(width: 10), // Text column Flexible( @@ -403,50 +406,6 @@ class _PostDetailScreenState extends State { ); } - /// Build community avatar or fallback - Widget _buildCommunityAvatar(CommunityRef community) { - const size = 28.0; - - if (community.avatar != null && community.avatar!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(size / 2), - child: CachedNetworkImage( - imageUrl: community.avatar!, - width: size, - height: size, - fit: BoxFit.cover, - placeholder: (context, url) => _buildFallbackAvatar(community, size), - errorWidget: (_, __, ___) => _buildFallbackAvatar(community, size), - ), - ); - } - - return _buildFallbackAvatar(community, size); - } - - /// Build fallback avatar with first letter and hash-based color - Widget _buildFallbackAvatar(CommunityRef community, double size) { - final name = community.name; - final firstLetter = name.isNotEmpty ? name[0].toUpperCase() : 'C'; - final bgColor = DisplayUtils.getFallbackColor(name); - - return Container( - width: size, - height: size, - decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle), - child: Center( - child: Text( - firstLetter, - style: TextStyle( - color: Colors.white, - fontSize: size * 0.45, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - /// Handle menu action selection Future _handleMenuAction(String action) async { // Haptic feedback is non-essential, silently fail if unsupported diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index d6f6b70..95edcd7 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -7,12 +7,15 @@ import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; import '../../utils/responsive_utils.dart'; import '../../models/comment.dart'; +import '../../models/post.dart'; import '../../models/user_profile.dart'; import '../../providers/auth_provider.dart'; import '../../providers/block_provider.dart'; import '../../providers/user_profile_provider.dart'; +import '../../utils/pagination_scroll_listener.dart'; import '../../widgets/comment_card.dart'; import '../../widgets/loading_error_states.dart'; +import '../../widgets/paginated_sliver_list.dart'; import '../../widgets/post_card.dart'; import '../../widgets/primary_button.dart'; import '../../widgets/profile_header.dart'; @@ -37,14 +40,76 @@ class _ProfileScreenState extends State { int _selectedTabIndex = 0; bool _commentsLoadedOnce = false; + final ScrollController _scrollController = ScrollController(); + late final PaginationScrollListener _paginationListener; + + // One pending post-frame "does the content fill the viewport?" check. + bool _viewportFillCheckScheduled = false; + @override void initState() { super.initState(); + // Pagination is driven by the scroll position, not by the item builder: + // triggering a load from build() re-enters the provider mid-frame. + _paginationListener = PaginationScrollListener( + controller: _scrollController, + onLoadMore: _loadMoreForActiveTab, + )..attach(); + WidgetsBinding.instance.addPostFrameCallback((_) { _loadProfile(); }); } + @override + void dispose() { + _paginationListener.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _loadMoreForActiveTab() { + if (!mounted) { + return; + } + final profileProvider = context.read(); + if (_selectedTabIndex == 0) { + profileProvider.loadMorePosts(); + } else if (_selectedTabIndex == 1) { + profileProvider.loadMoreComments(); + } + } + + /// Keep loading while the active tab's content does not fill the + /// viewport. + /// + /// [PaginationScrollListener] only fires on scroll events. The build-phase + /// trigger this screen used to have covered the short-first-page case by + /// accident; this covers it on purpose, after layout instead of during + /// it. Scheduled from build, at most one callback outstanding. + void _scheduleViewportFillCheck({ + required bool isLoading, + required bool isLoadingMore, + required bool hasMore, + required String? loadMoreError, + }) { + if (_viewportFillCheckScheduled || + isLoading || + isLoadingMore || + loadMoreError != null || + !hasMore) { + return; + } + + _viewportFillCheckScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _viewportFillCheckScheduled = false; + if (mounted) { + _paginationListener.checkNow(); + } + }); + } + @override void didUpdateWidget(ProfileScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -280,6 +345,7 @@ class _ProfileScreenState extends State { } }, child: CustomScrollView( + controller: _scrollController, slivers: [ // Collapsing app bar with profile header and frosted glass effect SliverAppBar( @@ -474,7 +540,9 @@ class _ProfileScreenState extends State { ); } - // Error state for posts + // Error state for posts — only while there is nothing to read. With + // posts on screen the same failure (a pull-to-refresh that failed) + // goes to the footer below instead of blanking the tab. if (postsState.error != null && postsState.posts.isEmpty) { return SliverFillRemaining( child: Center( @@ -486,46 +554,32 @@ class _ProfileScreenState extends State { ); } - // Empty state - if (postsState.posts.isEmpty && !postsState.isLoading) { - return const SliverFillRemaining( - child: Center( - child: Text( - 'No posts yet', - style: TextStyle(fontSize: 16, color: AppColors.textSecondary), - ), - ), - ); - } - - // Posts list - // Only add extra slot for loading/error indicators, not just hasMore - final showLoadingSlot = - postsState.isLoadingMore || postsState.error != null; - - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - // Load more when reaching end - if (index == postsState.posts.length - 3 && postsState.hasMore) { - profileProvider.loadMorePosts(); - } - - // Show loading indicator or error at the end - if (index == postsState.posts.length) { - if (postsState.isLoadingMore) { - return const InlineLoading(); - } - if (postsState.error != null) { - return InlineError( - message: postsState.error!, - onRetry: () => profileProvider.loadMorePosts(), - ); - } - // Shouldn't reach here due to showLoadingSlot check - return const SizedBox.shrink(); - } + _scheduleViewportFillCheck( + isLoading: postsState.isLoading, + isLoadingMore: postsState.isLoadingMore, + hasMore: postsState.hasMore, + loadMoreError: postsState.loadMoreError, + ); - final feedViewPost = postsState.posts[index]; + // Posts list. Pagination failures show in the footer via + // loadMoreError; a failed refresh shows there too, via refreshError. + return PaginatedSliverList( + items: postsState.posts, + isLoadingMore: postsState.isLoadingMore, + hasMore: postsState.hasMore, + loadMoreError: postsState.loadMoreError, + refreshError: postsState.posts.isEmpty ? null : postsState.error, + onRetryRefresh: () => profileProvider.retryPosts(), + onRetryLoadMore: profileProvider.retryLoadMorePosts, + idOf: (feedViewPost) => feedViewPost.post.uri, + footerKey: const ValueKey('profile_posts_footer'), + emptyWidget: const Center( + child: Text( + 'No posts yet', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + ), + ), + itemBuilder: (context, feedViewPost, index) { final postCard = PostCard(post: feedViewPost); // Constrain width on tablets for better readability @@ -540,7 +594,7 @@ class _ProfileScreenState extends State { ); } return postCard; - }, childCount: postsState.posts.length + (showLoadingSlot ? 1 : 0)), + }, ); } @@ -568,45 +622,33 @@ class _ProfileScreenState extends State { ); } - // Empty state - if (commentsState.comments.isEmpty && !commentsState.isLoading) { - return const SliverFillRemaining( - child: Center( - child: Text( - 'No comments yet', - style: TextStyle(fontSize: 16, color: AppColors.textSecondary), - ), - ), - ); - } - - // Comments list - final showLoadingSlot = - commentsState.isLoadingMore || commentsState.error != null; - - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - // Load more when reaching end - if (index == commentsState.comments.length - 3 && - commentsState.hasMore) { - profileProvider.loadMoreComments(); - } - - // Show loading indicator or error at the end - if (index == commentsState.comments.length) { - if (commentsState.isLoadingMore) { - return const InlineLoading(); - } - if (commentsState.error != null) { - return InlineError( - message: commentsState.error!, - onRetry: () => profileProvider.loadMoreComments(), - ); - } - return const SizedBox.shrink(); - } + _scheduleViewportFillCheck( + isLoading: commentsState.isLoading, + isLoadingMore: commentsState.isLoadingMore, + hasMore: commentsState.hasMore, + loadMoreError: commentsState.loadMoreError, + ); - final comment = commentsState.comments[index]; + // Comments list — same footer/error split as the posts tab. + return PaginatedSliverList( + items: commentsState.comments, + isLoadingMore: commentsState.isLoadingMore, + hasMore: commentsState.hasMore, + loadMoreError: commentsState.loadMoreError, + refreshError: commentsState.comments.isEmpty + ? null + : commentsState.error, + onRetryRefresh: () => profileProvider.retryComments(), + onRetryLoadMore: profileProvider.retryLoadMoreComments, + idOf: (comment) => comment.uri, + footerKey: const ValueKey('profile_comments_footer'), + emptyWidget: const Center( + child: Text( + 'No comments yet', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + ), + ), + itemBuilder: (context, comment, index) { final commentCard = _ProfileCommentCard(comment: comment); // Constrain width on tablets for better readability @@ -621,7 +663,7 @@ class _ProfileScreenState extends State { ); } return commentCard; - }, childCount: commentsState.comments.length + (showLoadingSlot ? 1 : 0)), + }, ); } diff --git a/lib/utils/cursor_pagination_controller.dart b/lib/utils/cursor_pagination_controller.dart new file mode 100644 index 0000000..f381409 --- /dev/null +++ b/lib/utils/cursor_pagination_controller.dart @@ -0,0 +1,359 @@ +import 'package:flutter/foundation.dart'; + +/// One page of a cursor-paginated collection. +/// +/// [cursor] is the cursor to pass to the *next* fetch. A null or empty +/// cursor means the collection has been fully read. +class CursorPage { + const CursorPage({required this.items, this.cursor}); + + final List items; + final String? cursor; +} + +/// Shared cursor pagination state machine. +/// +/// Owns the items/cursor/loading/error state every paginated surface needs, +/// with the guards that used to be re-implemented (inconsistently) per +/// screen: +/// +/// - **refresh supersedes**: a [refresh] always starts a fetch and takes +/// over, whatever is in flight. Dropping it (the old single-flight rule) +/// left the previous sort's — or previous profile's — content on screen +/// under the new label whenever the user switched during the first load. +/// - **loadMore is single flight**: a second [loadMore] while one is in +/// flight, or while the first page is loading, is ignored. +/// - **generation counter**: pages from a superseded request are discarded +/// whole when they land — no state, no error, no hydration. +/// - **separate error channels**: a pagination failure lands on +/// [loadMoreError] and never on [error], which drives full-screen error +/// states. [refresh] clears both. +/// - **a footer error stops the trigger**: [loadMore] is a no-op while +/// [loadMoreError] is set, so the scroll trigger cannot re-fire a failing +/// page ten times a second. The footer's Retry goes through +/// [retryLoadMore]. +/// - **flags always clear**: loading flags are cleared on the failure path +/// as well as the success path, before any user-supplied callback runs, +/// so a throw can never wedge the feed. +/// - **new list instances**: appends never mutate the previously exposed +/// list, so widgets that captured it still see what they rendered. +/// +/// `fetchPage` receives the cursor to fetch (null for the first page). +/// `onPageLoaded` is an optional hydration hook (vote / subscription +/// seeding); it receives only the new items and runs *after* the append is +/// visible on the controller. Its failures never corrupt a page that loaded +/// fine, but they are reported through `onUnexpectedError`. +/// `errorMapper` converts a thrown object into the user-facing message for +/// both error channels; without one the error's own text is used. +/// `idOf` is the item's stable server id. When supplied, items whose id is +/// already loaded are dropped instead of appended: overlapping pages from +/// server-side cursor drift would otherwise crash the list, which keys its +/// rows by that same id. +/// `onUnexpectedError` receives every error the controller swallows — +/// fetch failures, hydration failures, and failures of superseded requests +/// — so they reach crash reporting instead of only the debug console. +class CursorPaginationController extends ChangeNotifier { + CursorPaginationController({ + required Future> Function(String? cursor) fetchPage, + Future Function(List newItems)? onPageLoaded, + String Function(Object error)? errorMapper, + String Function(T item)? idOf, + void Function(Object error, StackTrace stack)? onUnexpectedError, + }) : _fetchPage = fetchPage, + _onPageLoaded = onPageLoaded, + _errorMapper = errorMapper, + _idOf = idOf, + _onUnexpectedError = onUnexpectedError; + + final Future> Function(String? cursor) _fetchPage; + final Future Function(List newItems)? _onPageLoaded; + final String Function(Object error)? _errorMapper; + final String Function(T item)? _idOf; + final void Function(Object error, StackTrace stack)? _onUnexpectedError; + + List _items = List.unmodifiable(const []); + String? _cursor; + bool _isLoading = false; + bool _isLoadingMore = false; + String? _error; + String? _loadMoreError; + bool _disposed = false; + + /// Bumped by every [refresh] and [reset] so in-flight requests started + /// under a previous generation can recognise themselves as stale. + int _generation = 0; + + /// The loaded items (unmodifiable; a new instance on every change). + List get items => _items; + + /// Cursor for the next page, or null/empty at the end of the collection. + String? get cursor => _cursor; + + /// Whether the server handed back a cursor to follow. False before the + /// first page has loaded — there is nothing to page through yet. + bool get hasMore => _cursor != null && _cursor!.isNotEmpty; + + /// First-page load in flight. + bool get isLoading => _isLoading; + + /// Pagination load in flight. + bool get isLoadingMore => _isLoadingMore; + + /// First-page error — the full-screen error channel. + /// + /// Also set when a pull-to-refresh fails with items already on screen. + /// Screens gate their full-screen error on an empty list and surface this + /// in the list footer otherwise (`PaginatedSliverList.refreshError`). + String? get error => _error; + + /// Pagination error — the footer error channel. + String? get loadMoreError => _loadMoreError; + + /// Load (or reload) the first page. + /// + /// Also the entry point for the initial load: there is no separate + /// `load()`, since a first load *is* a refresh with a null cursor. + /// + /// Supersedes anything in flight — including another refresh — because + /// the caller's fetcher closes over screen state (sort, profile DID) that + /// may have changed since the in-flight call started. + /// + /// Never rethrows; failures land on [error]. Returns true when *this* + /// call's page reached the controller, false when it failed or was + /// superseded, so awaiters do not stamp "refreshed at" for a fetch that + /// never landed. + Future refresh() async { + final generation = ++_generation; + _isLoading = true; + // Any load-more in flight now belongs to a previous generation. + _isLoadingMore = false; + _error = null; + _loadMoreError = null; + _notify(); + + try { + final page = await _fetchPage(null); + if (_isStale(generation)) { + return false; + } + + final fresh = _withoutDuplicates(page.items, const []); + _items = List.unmodifiable(fresh); + _cursor = page.cursor; + _isLoading = false; + _notify(); + + await _hydrate(fresh); + return true; + } on Object catch (error, stack) { + if (_isStale(generation)) { + _report(error, stack); + return false; + } + + // Flags first: everything below can run user-supplied code, and a + // throw there must not leave the feed stuck in a loading state. + _isLoading = false; + _error = _messageFor(error); + _notify(); + _report(error, stack); + + if (kDebugMode) { + debugPrint('❌ Pagination first page failed: $error'); + } + return false; + } + } + + /// Append the next page. + /// + /// A no-op while any load is in flight, once the collection has ended, or + /// while a [loadMoreError] is showing — the scroll trigger fires on every + /// scroll tick, and without that last guard a failing page is retried + /// continuously while the user sits at the bottom of the list. + /// + /// Never rethrows; failures land on [loadMoreError] and leave the loaded + /// items, cursor and [hasMore] intact so a retry can resume. + Future loadMore() async { + if (_isLoading || _isLoadingMore || _loadMoreError != null || !hasMore) { + return; + } + + final generation = _generation; + _isLoadingMore = true; + _notify(); + + try { + final page = await _fetchPage(_cursor); + if (_isStale(generation)) { + return; + } + + final fresh = _withoutDuplicates(page.items, _items); + _items = List.unmodifiable([..._items, ...fresh]); + // A page with no items ends the collection even when the server hands + // back another cursor: following it again would poll the same empty + // page forever. + _cursor = page.items.isEmpty ? null : page.cursor; + _isLoadingMore = false; + _notify(); + + await _hydrate(fresh); + } on Object catch (error, stack) { + if (_isStale(generation)) { + _report(error, stack); + return; + } + + // Flags first — see refresh(). + _isLoadingMore = false; + _loadMoreError = _messageFor(error); + _notify(); + _report(error, stack); + + if (kDebugMode) { + debugPrint('❌ Pagination next page failed: $error'); + } + } + } + + /// Retry the first page after a failure. + Future retry() => refresh(); + + /// The footer's Retry: clears the pagination error that [loadMore] treats + /// as a stop sign, then fetches the page again. + Future retryLoadMore() async { + clearLoadMoreError(); + await loadMore(); + } + + /// Dismiss the footer error without touching anything else. + void clearLoadMoreError() { + if (_loadMoreError == null) { + return; + } + _loadMoreError = null; + _notify(); + } + + /// Drop everything back to the pre-first-load state and orphan any + /// in-flight request. + void reset() { + _generation++; + _items = List.unmodifiable(const []); + _cursor = null; + _isLoading = false; + _isLoadingMore = false; + _error = null; + _loadMoreError = null; + _notify(); + } + + /// Drop the items matching [test] (e.g. a comment the user deleted). + /// + /// Leaves the cursor alone: the server-side page boundaries do not move + /// just because the client stopped showing a row. + void removeWhere(bool Function(T item) test) { + final remaining = _items.where((item) => !test(item)).toList(); + if (remaining.length == _items.length) { + return; + } + _items = List.unmodifiable(remaining); + _notify(); + } + + /// A request is stale when the controller is gone or a newer generation + /// has taken over. Stale results are dropped whole: no state, no error, + /// no hydration hook. + bool _isStale(int generation) => _disposed || generation != _generation; + + /// [incoming] minus anything whose id is already in [existing] or earlier + /// in [incoming] itself. Identity-free (no `idOf`) collections are taken + /// verbatim. + List _withoutDuplicates(List incoming, List existing) { + final idOf = _idOf; + if (idOf == null) { + return incoming; + } + + final seen = existing.map(idOf).toSet(); + final result = []; + for (final item in incoming) { + if (seen.add(idOf(item))) { + result.add(item); + } + } + return result; + } + + Future _hydrate(List newItems) async { + final hook = _onPageLoaded; + if (hook == null) { + return; + } + try { + await hook(List.unmodifiable(newItems)); + } on Object catch (error, stack) { + // Hydration is best-effort for the page (which already loaded), but + // it is never silent: a failure here means viewer state — votes, + // subscriptions — is missing from what the user sees. + _report(error, stack); + if (kDebugMode) { + debugPrint('⚠️ Pagination hydration hook failed: $error'); + } + } + } + + /// Hand [error] to the crash reporter, if the owner wired one up. + /// + /// The reporter is untrusted: it runs on the failure path, where a throw + /// would escape [refresh] / [loadMore] and wedge the loading flags. + void _report(Object error, StackTrace stack) { + final onUnexpectedError = _onUnexpectedError; + if (onUnexpectedError == null) { + return; + } + try { + onUnexpectedError(error, stack); + } on Object catch (reportFailure) { + if (kDebugMode) { + debugPrint('⚠️ Pagination error reporter threw: $reportFailure'); + } + } + } + + /// The user-facing message for [error]. + /// + /// Both the mapper and [Object.toString] are treated as untrusted for the + /// same reason as [_report]. + String _messageFor(Object error) { + final mapper = _errorMapper; + if (mapper != null) { + try { + return mapper(error); + } on Object catch (mapperFailure) { + if (kDebugMode) { + debugPrint('⚠️ Pagination errorMapper threw: $mapperFailure'); + } + } + } + try { + return error.toString(); + } on Object { + return 'Something went wrong. Please try again.'; + } + } + + void _notify() { + if (_disposed) { + return; + } + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/utils/date_time_utils.dart b/lib/utils/date_time_utils.dart index 2c9e9a5..717ee34 100644 --- a/lib/utils/date_time_utils.dart +++ b/lib/utils/date_time_utils.dart @@ -1,8 +1,9 @@ /// DateTime utility functions /// -/// Provides reusable time formatting and number formatting utilities. -/// All functions accept current time as parameter to enable testing -/// without relying on DateTime.now(). +/// Provides reusable time formatting utilities. All functions accept current +/// time as parameter to enable testing without relying on DateTime.now(). +/// +/// Number formatting lives in `DisplayUtils.formatCount`. class DateTimeUtils { // Private constructor to prevent instantiation DateTimeUtils._(); @@ -33,22 +34,6 @@ class DateTimeUtils { } } - /// Format large numbers with 'k' suffix for thousands - /// - /// Examples: - /// - 0-999: "0", "42", "999" - /// - 1000+: "1.0k", "5.2k", "12.5k" - /// - /// [count] is the number to format - static String formatCount(int count) { - if (count < 1000) { - return count.toString(); - } else { - final thousands = count / 1000; - return '${thousands.toStringAsFixed(1)}k'; - } - } - /// Format datetime as full date/time string like Bluesky /// /// Example: "12:01PM · Dec 26, 2025" diff --git a/lib/utils/facet_detector.dart b/lib/utils/facet_detector.dart index 8880457..51973eb 100644 --- a/lib/utils/facet_detector.dart +++ b/lib/utils/facet_detector.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../models/facet.dart'; +import 'url_policy.dart'; class FacetDetector { // Private constructor to prevent instantiation @@ -64,7 +65,7 @@ class FacetDetector { final normalizedUrl = _normalizeUrl(trimmed); // Validate the normalized URL - if (!_isValidUrl(normalizedUrl)) { + if (!isAllowedWebUrl(normalizedUrl)) { continue; } @@ -186,34 +187,4 @@ class FacetDetector { return 'https://$trimmed'; } - /// Validate that a string is a valid URL - /// - /// Basic validation to ensure the URL has a valid scheme and host. - static bool _isValidUrl(String url) { - if (url.isEmpty) { - return false; - } - - final uri = Uri.tryParse(url); - if (uri == null) { - return false; - } - - // Must have a scheme (http or https) - if (!uri.hasScheme) { - return false; - } - - final scheme = uri.scheme.toLowerCase(); - if (scheme != 'http' && scheme != 'https') { - return false; - } - - // Must have a host - if (!uri.hasAuthority || uri.host.isEmpty) { - return false; - } - - return true; - } } diff --git a/lib/utils/pagination_scroll_listener.dart b/lib/utils/pagination_scroll_listener.dart new file mode 100644 index 0000000..d387f98 --- /dev/null +++ b/lib/utils/pagination_scroll_listener.dart @@ -0,0 +1,98 @@ +import 'package:flutter/widgets.dart'; + +/// Fires [onLoadMore] when a scroll view gets within [threshold] pixels of +/// its bottom, at most once per [throttle] window. +/// +/// The listener borrows an externally owned [controller]: it attaches and +/// detaches, and never disposes it. Callers own the lifecycle — +/// [attach] after the controller has a client (typically in `initState`) +/// and [dispose] from the widget's `dispose`. +/// +/// `clock` exists so the throttle can be driven deterministically in tests; +/// production code leaves it at [DateTime.now]. +class PaginationScrollListener { + PaginationScrollListener({ + required this.controller, + required this.onLoadMore, + this.threshold = 200, + this.throttle = const Duration(milliseconds: 100), + DateTime Function() clock = DateTime.now, + }) : _clock = clock; + + /// The scroll controller to observe. Not owned; never disposed here. + final ScrollController controller; + + /// Called when the viewport nears the bottom. + final VoidCallback onLoadMore; + + /// How close to the bottom (in pixels) counts as "near". + final double threshold; + + /// Minimum gap between two callbacks. Scroll notifications arrive per + /// frame; without this a single flick fires a burst of load requests. + final Duration throttle; + + final DateTime Function() _clock; + + bool _attached = false; + DateTime? _lastFiredAt; + + /// Whether the listener is currently observing the controller. + bool get isAttached => _attached; + + /// Start observing. Idempotent — a second call does not double-fire. + void attach() { + if (_attached) { + return; + } + controller.addListener(_onScroll); + _attached = true; + } + + /// Stop observing. Idempotent, and safe to call after the borrowed + /// controller has been disposed ([ChangeNotifier.removeListener] is + /// documented as callable on a disposed notifier). + void detach() { + if (!_attached) { + return; + } + controller.removeListener(_onScroll); + _attached = false; + } + + /// Detaches. The borrowed [controller] is left alone. + void dispose() => detach(); + + /// Fire [onLoadMore] now if the viewport is already at (or near) the + /// bottom, subject to the same throttle as a scroll trigger. + /// + /// A scroll listener only runs on scroll events, so a first page shorter + /// than the viewport leaves nothing to scroll and pagination stalls + /// forever. Screens call this after a page lands (post-frame, while + /// `hasMore` and nothing is in flight) to keep filling the viewport. + /// A detached listener stays silent — detaching means the owner has + /// stopped paginating. + void checkNow() => _maybeFire(); + + void _onScroll() => _maybeFire(); + + void _maybeFire() { + if (!_attached || !controller.hasClients) { + return; + } + + final position = controller.position; + if (position.pixels < position.maxScrollExtent - threshold) { + return; + } + + final now = _clock(); + final last = _lastFiredAt; + if (last != null && now.difference(last) < throttle) { + return; + } + + _lastFiredAt = now; + onLoadMore(); + } +} diff --git a/lib/utils/url_display.dart b/lib/utils/url_display.dart new file mode 100644 index 0000000..88fedce --- /dev/null +++ b/lib/utils/url_display.dart @@ -0,0 +1,44 @@ +/// Display helpers for URLs that came out of a record. +/// +/// Three surfaces used to carry their own copy of the parse-and-fall-back +/// dance (the two link bars and the detail view's link rows). Both functions +/// here are total: the input is untrusted record data, so a malformed URL +/// yields a fallback rather than an exception. +library; + +/// The host of [url], lowercased, or null when it has none — unparseable, +/// relative, or authority-less. Never throws. +String? domainOf(String url) { + final host = _tryParse(url)?.host; + if (host == null || host.isEmpty) { + return null; + } + return host.toLowerCase(); +} + +/// [url] with the scheme, port, query and fragment stripped: +/// `example.com` or `example.com/a/b`. +/// +/// Returns [url] unchanged when it has no host, so a bare string still shows +/// the user something. Never throws. +String hostAndPath(String url) { + final uri = _tryParse(url); + if (uri == null || uri.host.isEmpty) { + return url; + } + + final path = uri.path; + if (path.isEmpty || path == '/') { + return uri.host; + } + return '${uri.host}$path'; +} + +/// [Uri.parse] without the throw. +Uri? _tryParse(String url) { + try { + return Uri.parse(url); + } on FormatException { + return null; + } +} diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 8d2f257..0d28e0e 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'url_policy.dart'; + /// Utility class for safely launching external URLs /// /// Provides security validation and error handling for opening URLs @@ -9,15 +11,12 @@ import 'package:url_launcher/url_launcher.dart'; class UrlLauncher { UrlLauncher._(); // Private constructor to prevent instantiation - /// Allowed URL schemes for security - static const _allowedSchemes = ['http', 'https']; - /// Launches an external URL with security validation /// /// Returns true if the URL was successfully launched, false otherwise. /// /// Security: - /// - Only allows http and https schemes + /// - Only allows http(s) urls with a host (see [isAllowedWebUrl]) /// - Blocks potentially malicious schemes (javascript:, file:, etc.) /// - Opens in external browser for user control /// @@ -30,10 +29,12 @@ class UrlLauncher { try { final uri = Uri.parse(url); - // Validate URL scheme for security - if (!_allowedSchemes.contains(uri.scheme.toLowerCase())) { + // Validate URL scheme and host for security + if (!isAllowedWebUrl(url)) { if (kDebugMode) { - debugPrint('Blocked non-http(s) URL scheme: ${uri.scheme}'); + debugPrint( + 'Blocked URL (scheme "${uri.scheme}", host "${uri.host}")', + ); } _showErrorIfPossible(context, 'Invalid link format'); return false; diff --git a/lib/utils/url_policy.dart b/lib/utils/url_policy.dart new file mode 100644 index 0000000..38410cb --- /dev/null +++ b/lib/utils/url_policy.dart @@ -0,0 +1,34 @@ +// The single canonical http/https allowlist. +// +// Every place that decides whether a user- or network-supplied string may be +// opened, rendered or posted as a web link routes through [isAllowedWebUrl]. +// Keep this file free of Flutter imports: the model layer depends on it, and +// models must stay usable without a widget binding. + +/// URL schemes the app is willing to treat as web links. +const Set kAllowedWebSchemes = {'http', 'https'}; + +/// Whether [url] is a web link the app may open, render or publish. +/// +/// True iff [url] parses, carries a scheme in [kAllowedWebSchemes] +/// (case-insensitively), and has a non-empty host. +/// +/// The host check is load-bearing, not belt-and-braces: `http:foo` and +/// `https:///path` carry an allowed scheme with no authority at all, and a +/// scheme prefix test (`scheme.startsWith('http')`) would wave through +/// `httpx://evil.com`. Never throws, whatever the input. +bool isAllowedWebUrl(String? url) { + if (url == null || url.isEmpty) { + return false; + } + + final parsed = Uri.tryParse(url); + if (parsed == null) { + return false; + } + + // Uri lowercases the scheme while parsing, but compare case-insensitively + // anyway so 'HTTPS://…' cannot turn on a future refactor. + return kAllowedWebSchemes.contains(parsed.scheme.toLowerCase()) && + parsed.host.isNotEmpty; +} diff --git a/lib/widgets/bluesky_post_card.dart b/lib/widgets/bluesky_post_card.dart index db01c55..4c2b3c3 100644 --- a/lib/widgets/bluesky_post_card.dart +++ b/lib/widgets/bluesky_post_card.dart @@ -1,5 +1,4 @@ import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../constants/bluesky_colors.dart'; @@ -7,7 +6,9 @@ import '../constants/bluesky_icons.dart'; import '../models/bluesky_post.dart'; import '../models/post.dart'; import '../utils/date_time_utils.dart'; +import '../utils/display_utils.dart'; import '../utils/url_launcher.dart'; +import 'user_avatar.dart'; /// Bluesky post card widget for displaying Bluesky crossposts /// @@ -217,7 +218,7 @@ class BlueskyPostCard extends StatelessWidget { if (count != null && count > 0) ...[ const SizedBox(width: 4), Text( - DateTimeUtils.formatCount(count), + DisplayUtils.formatCount(count), style: const TextStyle( color: BlueskyColors.actionColor, fontSize: 13, @@ -237,7 +238,7 @@ class BlueskyPostCard extends StatelessWidget { if (count != null && count > 0) ...[ const SizedBox(width: 4), Text( - DateTimeUtils.formatCount(count), + DisplayUtils.formatCount(count), style: const TextStyle( color: BlueskyColors.actionColor, fontSize: 13, @@ -304,54 +305,14 @@ class BlueskyPostCard extends StatelessWidget { /// Builds the avatar widget with fallback Widget _buildAvatar(AuthorView author) { - final avatarUrl = author.avatar; - if (avatarUrl != null && avatarUrl.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(20), - child: CachedNetworkImage( - imageUrl: avatarUrl, - width: 40, - height: 40, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildFallbackAvatar(author), - errorWidget: (context, url, error) { - if (kDebugMode) { - debugPrint('Failed to load avatar from $url: $error'); - } - return _buildFallbackAvatar(author); - }, - ), - ); - } - - return _buildFallbackAvatar(author); - } - - /// Builds a fallback avatar with the first letter of display name or handle - Widget _buildFallbackAvatar(AuthorView author) { - final text = author.displayName ?? author.handle; - final firstLetter = text.isNotEmpty ? text[0].toUpperCase() : '?'; - - return Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: BlueskyColors.avatarFallback, - borderRadius: BorderRadius.circular(20), - ), - child: Center( - child: Text( - firstLetter, - style: const TextStyle( - color: BlueskyColors.textSecondary, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), + // Bluesky-embedded posts keep their own themed fallback rather than the + // Coves hash palette, so the embed reads as Bluesky chrome. + return UserAvatar( + name: author.displayName ?? author.handle, + avatarUrl: author.avatar, + size: 40, + fallbackColor: BlueskyColors.avatarFallback, + fallbackTextColor: BlueskyColors.textSecondary, ); } @@ -622,54 +583,12 @@ class BlueskyPostCard extends StatelessWidget { /// Builds a small avatar widget with fallback for quoted posts Widget _buildSmallAvatar(AuthorView author) { - final avatarUrl = author.avatar; - if (avatarUrl != null && avatarUrl.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: avatarUrl, - width: 20, - height: 20, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildSmallFallbackAvatar(author), - errorWidget: (context, url, error) { - if (kDebugMode) { - debugPrint('Failed to load avatar from $url: $error'); - } - return _buildSmallFallbackAvatar(author); - }, - ), - ); - } - - return _buildSmallFallbackAvatar(author); - } - - /// Builds a small fallback avatar for quoted posts - Widget _buildSmallFallbackAvatar(AuthorView author) { - final text = author.displayName ?? author.handle; - final firstLetter = text.isNotEmpty ? text[0].toUpperCase() : '?'; - - return Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: BlueskyColors.avatarFallback, - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Text( - firstLetter, - style: const TextStyle( - color: BlueskyColors.textSecondary, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), + return UserAvatar( + name: author.displayName ?? author.handle, + avatarUrl: author.avatar, + size: 20, + fallbackColor: BlueskyColors.avatarFallback, + fallbackTextColor: BlueskyColors.textSecondary, ); } diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index 9e9c8ca..f8c49c8 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,14 +12,16 @@ import '../providers/auth_provider.dart'; import '../providers/block_provider.dart'; import '../providers/vote_provider.dart'; import '../services/api_exceptions.dart'; -import '../utils/error_messages.dart'; import '../utils/date_time_utils.dart'; +import '../utils/display_utils.dart'; +import '../utils/error_messages.dart'; import 'block_action_helpers.dart'; import 'icons/animated_heart_icon.dart'; import 'report_dialog.dart'; import 'rich_text_renderer.dart'; import 'sign_in_dialog.dart'; import 'tappable_author.dart'; +import 'user_avatar.dart'; /// Comment card widget for displaying individual comments /// @@ -268,48 +269,10 @@ class _CommentCardState extends State { /// Builds the author avatar widget Widget _buildAuthorAvatar(AuthorView author) { - if (author.avatar != null && author.avatar!.isNotEmpty) { - // Show real author avatar - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: author.avatar!, - width: 14, - height: 14, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildFallbackAvatar(author), - errorWidget: (context, url, error) => _buildFallbackAvatar(author), - ), - ); - } - - // Fallback to letter placeholder - return _buildFallbackAvatar(author); - } - - /// Builds a fallback avatar with the first letter of handle - Widget _buildFallbackAvatar(AuthorView author) { - final firstLetter = author.handle.isNotEmpty ? author.handle[0] : '?'; - return Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - firstLetter.toUpperCase(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), + return UserAvatar( + name: author.displayName ?? author.handle, + avatarUrl: author.avatar, + size: 24, ); } @@ -685,7 +648,7 @@ class _CommentCardState extends State { ), const SizedBox(width: 5), Text( - DateTimeUtils.formatCount(adjustedScore), + DisplayUtils.formatCount(adjustedScore), style: TextStyle( color: AppColors.textPrimary.withValues(alpha: 0.6), fontSize: 12, diff --git a/lib/widgets/comments_header.dart b/lib/widgets/comments_header.dart index a49dcd9..5d3856e 100644 --- a/lib/widgets/comments_header.dart +++ b/lib/widgets/comments_header.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../constants/app_colors.dart'; +import '../utils/display_utils.dart'; /// Comments section header with sort dropdown /// @@ -95,7 +96,7 @@ class CommentsHeader extends StatelessWidget { ), const SizedBox(width: 6), Text( - '$commentCount ' + '${DisplayUtils.formatCount(commentCount)} ' '${commentCount == 1 ? 'Comment' : 'Comments'}', style: const TextStyle( fontSize: 15, diff --git a/lib/widgets/community_avatar.dart b/lib/widgets/community_avatar.dart index 8486781..ef7d8b7 100644 --- a/lib/widgets/community_avatar.dart +++ b/lib/widgets/community_avatar.dart @@ -28,8 +28,10 @@ class CommunityAvatar extends StatelessWidget { this.avatarUrl, this.shape = CommunityAvatarShape.circle, this.borderRadius = 14.0, + this.fallbackColor, this.fallbackColorAlpha = 1.0, this.fallbackBorder, + this.fallbackIcon, this.showLoadingIndicator = false, super.key, }); @@ -49,12 +51,19 @@ class CommunityAvatar extends StatelessWidget { /// Border radius when [shape] is [CommunityAvatarShape.roundedRect]. final double borderRadius; + /// Fallback background color. Defaults to the deterministic hash color for + /// [name] so the same community looks the same everywhere. + final Color? fallbackColor; + /// Alpha value applied to the fallback background color (0.0 - 1.0). final double fallbackColorAlpha; /// Optional border on the fallback avatar. final BoxBorder? fallbackBorder; + /// Rendered instead of the name initial when set. + final Widget? fallbackIcon; + /// Whether to show a loading spinner while the image loads. final bool showLoadingIndicator; @@ -69,6 +78,10 @@ class CommunityAvatar extends StatelessWidget { if (shape == CommunityAvatarShape.roundedRect) { return CachedNetworkImage( imageUrl: avatarUrl!, + // Disable fade animation to prevent scroll jitter, same as the + // circle path and UserAvatar. + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, imageBuilder: (context, imageProvider) => Container( width: size, height: size, @@ -115,7 +128,7 @@ class CommunityAvatar extends StatelessWidget { } Widget _buildFallback() { - final baseColor = DisplayUtils.getFallbackColor(name); + final baseColor = fallbackColor ?? DisplayUtils.getFallbackColor(name); final isCircle = shape == CommunityAvatarShape.circle; return Container( @@ -128,14 +141,15 @@ class CommunityAvatar extends StatelessWidget { border: fallbackBorder, ), child: Center( - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - fontSize: size * 0.45, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + child: fallbackIcon ?? + Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: size * 0.45, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ), ); } diff --git a/lib/widgets/detailed_post_view.dart b/lib/widgets/detailed_post_view.dart index b882424..20a8789 100644 --- a/lib/widgets/detailed_post_view.dart +++ b/lib/widgets/detailed_post_view.dart @@ -7,37 +7,21 @@ import '../constants/app_colors.dart'; import '../models/post.dart'; import '../services/streamable_service.dart'; import '../utils/date_time_utils.dart'; +import '../utils/url_display.dart'; import '../utils/url_launcher.dart'; import 'bluesky_post_card.dart'; import 'external_link_bar.dart'; -import 'fullscreen_video_player.dart'; import 'image_viewer.dart'; -import 'post_card.dart' show formatVideoDuration; +import 'media/favicon.dart'; +import 'media/media_aspect.dart'; +import 'media/media_surface.dart'; +import 'media/native_image_embed.dart'; +import 'media/native_video_embed.dart'; +import 'media/streamable_video_embed.dart'; import 'rich_text_renderer.dart'; import 'source_link_bar.dart'; import 'tappable_author.dart'; - -/// Fallback ratio (width/height) for native media with no declared one. -const double _defaultMediaRatio = 16 / 9; - -/// Bounds for detail-view media, as width/height: 1:3 through 3:1. -const double _minDetailRatio = 1 / 3; -const double _maxDetailRatio = 3; - -/// Display ratio for a native image in the detail view. -/// -/// The detail view keeps far more of the true proportions than the feed card -/// does — every legitimate shape, from a 9:16 portrait to a 3:1 panorama, -/// survives untouched. The outer bounds exist purely as a safety rail: the -/// backend never validates `aspectRatio`, so a hostile record can declare -/// something like 1:1000000 and, unclamped, lay out a media block hundreds of -/// millions of pixels tall. -double _nativeAspectRatio(EmbedAspectRatio? ratio) { - if (ratio == null) { - return _defaultMediaRatio; - } - return (ratio.width / ratio.height).clamp(_minDetailRatio, _maxDetailRatio); -} +import 'user_avatar.dart'; /// Social media style post detail view inspired by Reddit's clean, /// content-first design. @@ -65,7 +49,9 @@ class DetailedPostView extends StatefulWidget { } class _DetailedPostViewState extends State { - // Image carousel state + // External-embed carousel state. The native gallery owns its own + // controller (see NativeImageGallery); this pair belongs to the legacy + // external#view carousel only. int _currentImageIndex = 0; final PageController _imagePageController = PageController(); @@ -74,7 +60,7 @@ class _DetailedPostViewState extends State { super.didUpdateWidget(oldWidget); // This State is reused when the same slot renders a different post, so - // gallery position has to be rewound by hand. Resetting the index alone + // carousel position has to be rewound by hand. Resetting the index alone // is not enough: the controller would keep the previous post's page on // screen while the indicator and the tap target both said page one. if (oldWidget.post.post.uri != widget.post.post.uri) { @@ -236,52 +222,10 @@ class _DetailedPostViewState extends State { /// Small circular avatar Widget _buildAvatar(AuthorView author) { - const size = 22.0; - - if (author.avatar != null && author.avatar!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(size / 2), - child: CachedNetworkImage( - imageUrl: author.avatar!, - width: size, - height: size, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildAvatarPlaceholder(author, size), - errorWidget: - (context, url, error) => _buildAvatarPlaceholder(author, size), - ), - ); - } - - return _buildAvatarPlaceholder(author, size); - } - - /// Placeholder avatar with initial - Widget _buildAvatarPlaceholder(AuthorView author, double size) { - final initial = - (author.displayName ?? author.handle).isNotEmpty - ? (author.displayName ?? author.handle)[0].toUpperCase() - : '?'; - - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: AppColors.coral.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: Center( - child: Text( - initial, - style: GoogleFonts.inter( - fontSize: size * 0.45, - fontWeight: FontWeight.w600, - color: AppColors.coral, - ), - ), - ), + return UserAvatar( + name: author.displayName ?? author.handle, + avatarUrl: author.avatar, + size: 22, ); } @@ -309,8 +253,14 @@ class _DetailedPostViewState extends State { case _ContentType.nativeGallery: return _buildNativeGallery(); case _ContentType.nativeVideo: - return _NativeVideoEmbed( - embed: widget.post.post.embed! as VideoPostEmbed, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: NativeVideoEmbed( + embed: widget.post.post.embed! as VideoPostEmbed, + keyPrefix: 'detail', + playChipStyle: PlayChipStyle.detail, + fill: kDetailMediaFill, + ), ); case _ContentType.video: return _buildVideoPlayer(); @@ -328,28 +278,16 @@ class _DetailedPostViewState extends State { /// opens the zoomable viewer. Widget _buildNativeSingleImage() { final embed = widget.post.post.embed! as ImagesPostEmbed; - final image = embed.images.first; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Semantics( - // An explicit container: without it this annotation is absorbed - // into the subtree's node, swallowing the image's alt-text label. - container: true, - explicitChildNodes: true, - button: true, - label: 'View full image', - child: GestureDetector( - key: const Key('detail-images-embed'), - onTap: () => ImageViewer.open(context, embed.images), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: AspectRatio( - aspectRatio: _nativeAspectRatio(image.aspectRatio), - child: _buildNativeImage(image), - ), - ), - ), + child: NativeImageThumb( + images: embed.images, + keyPrefix: 'detail', + bounds: kDetailRatioBounds, + source: MediaImageSource.fullsize, + fill: kDetailMediaFill, + onTap: () => ImageViewer.open(context, embed.images), ), ); } @@ -358,90 +296,29 @@ class _DetailedPostViewState extends State { /// indicator. No link bar — a native gallery has no uri to open. Widget _buildNativeGallery() { final embed = widget.post.post.embed! as ImagesPostEmbed; - final images = embed.images; - // The page controller is shared with the external carousel, so guard - // against an index left behind by a previously rendered post. - final current = _currentImageIndex < images.length ? _currentImageIndex : 0; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Semantics( - // An explicit container: without it this annotation is absorbed - // into the subtree's node, swallowing the page indicator's label. - container: true, - explicitChildNodes: true, - button: true, - label: 'View full image', - child: GestureDetector( - key: const Key('detail-images-embed'), - onTap: () => ImageViewer.open(context, images, initialIndex: current), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - children: [ - AspectRatio( - // The gallery frame follows the first image; the rest are - // contained inside it rather than resizing the carousel. - aspectRatio: _nativeAspectRatio(images.first.aspectRatio), - child: PageView.builder( - controller: _imagePageController, - itemCount: images.length, - onPageChanged: (index) { - setState(() => _currentImageIndex = index); - }, - itemBuilder: - (context, index) => _buildNativeImage( - images[index], - fit: BoxFit.contain, - ), - ), - ), - Positioned( - top: 8, - right: 8, - child: _NativeMediaBadge( - key: const Key('detail-images-page-indicator'), - label: '${current + 1}/${images.length}', - ), - ), - ], - ), - ), - ), + child: NativeImageGallery( + images: embed.images, + keyPrefix: 'detail', + onOpen: + (index) => + ImageViewer.open(context, embed.images, initialIndex: index), ), ); } - /// A single native image, carrying its alt text into the semantics tree. - Widget _buildNativeImage(EmbedImage image, {BoxFit fit = BoxFit.cover}) { - final alt = image.alt; - - Widget rendered = CachedNetworkImage( - imageUrl: image.fullsize, - width: double.infinity, - fit: fit, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => const _NativeMediaFill(), - errorWidget: - (context, url, error) => - const _NativeMediaFill(icon: Icons.broken_image), - ); - - if (alt != null && alt.isNotEmpty) { - rendered = Semantics(image: true, label: alt, child: rendered); - } - - return rendered; - } - /// Video player with play button overlay Widget _buildVideoPlayer() { final embed = widget.post.post.embed!.external!; - return _VideoEmbed( + return StreamableVideoEmbed( embed: embed, streamableService: context.read(), + height: 240, + darken: true, + playChipStyle: PlayChipStyle.detail, ); } @@ -527,11 +404,11 @@ class _DetailedPostViewState extends State { ), child: Row( children: [ - _buildFavicon(embed.uri), + Favicon(embed.uri, domain: embed.domain), const SizedBox(width: 8), Expanded( child: Text( - _formatUrlForDisplay(embed.uri), + hostAndPath(embed.uri), style: GoogleFonts.inter( fontSize: 13, color: AppColors.textPrimary.withValues(alpha: 0.7), @@ -645,11 +522,11 @@ class _DetailedPostViewState extends State { ), child: Row( children: [ - _buildFavicon(embed.uri), + Favicon(embed.uri, domain: embed.domain), const SizedBox(width: 8), Expanded( child: Text( - _formatUrlForDisplay(embed.uri), + hostAndPath(embed.uri), style: GoogleFonts.inter( fontSize: 13, color: AppColors.textPrimary.withValues(alpha: 0.7), @@ -755,356 +632,6 @@ class _DetailedPostViewState extends State { ), ); } - - /// Formats a URL for display (removes protocol, keeps domain + path start) - String _formatUrlForDisplay(String url) { - try { - final uri = Uri.parse(url); - final host = uri.host; - final path = uri.path; - - // Combine host and path, removing trailing slash if present - if (path.isEmpty || path == '/') { - return host; - } - return '$host$path'; - } on FormatException { - return url; - } - } - - /// Builds a favicon widget for the given URL - Widget _buildFavicon(String url) { - String? domain; - try { - final uri = Uri.parse(url); - domain = uri.host; - } on FormatException { - domain = null; - } - - if (domain == null || domain.isEmpty) { - return Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ); - } - - final faviconUrl = - 'https://www.google.com/s2/favicons?domain=$domain&sz=32'; - - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: faviconUrl, - width: 18, - height: 18, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: - (context, url) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - errorWidget: - (context, url, error) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - ), - ); - } -} - -/// Video embed with play button overlay -class _VideoEmbed extends StatefulWidget { - const _VideoEmbed({required this.embed, required this.streamableService}); - - final ExternalEmbed embed; - final StreamableService streamableService; - - @override - State<_VideoEmbed> createState() => _VideoEmbedState(); -} - -class _VideoEmbedState extends State<_VideoEmbed> { - bool _isLoading = false; - - bool get _isStreamable => - widget.embed.provider?.toLowerCase() == 'streamable'; - - Future _playVideo() async { - if (!_isStreamable || widget.embed.thumb == null) { - return; - } - - final messenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - - setState(() => _isLoading = true); - - try { - final videoUrl = await widget.streamableService.getVideoUrl( - widget.embed.uri, - ); - - if (!mounted) { - return; - } - - if (videoUrl == null) { - messenger.showSnackBar( - SnackBar( - content: Text( - 'Could not load video', - style: GoogleFonts.inter(color: AppColors.textPrimary), - ), - backgroundColor: AppColors.backgroundSecondary, - ), - ); - return; - } - - await navigator.push( - MaterialPageRoute( - builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl), - fullscreenDialog: true, - ), - ); - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - @override - Widget build(BuildContext context) { - if (widget.embed.thumb == null) { - return const SizedBox.shrink(); - } - - return Semantics( - button: true, - label: 'Play video', - child: GestureDetector( - onTap: _isLoading ? null : _playVideo, - child: Stack( - alignment: Alignment.center, - children: [ - // Video thumbnail - full width - CachedNetworkImage( - imageUrl: widget.embed.thumb!, - width: double.infinity, - height: 240, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: - (context, url) => Container( - height: 240, - color: AppColors.backgroundSecondary, - ), - errorWidget: - (context, url, error) => Container( - height: 240, - color: AppColors.backgroundSecondary, - child: const Center( - child: Icon( - Icons.broken_image, - color: AppColors.textMuted, - size: 40, - ), - ), - ), - ), - - // Darkening overlay - Positioned.fill( - child: Container(color: Colors.black.withValues(alpha: 0.3)), - ), - - // Play button - simple and clean - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: AppColors.textPrimary.withValues(alpha: 0.9), - shape: BoxShape.circle, - ), - child: - _isLoading - ? const Padding( - padding: EdgeInsets.all(18), - child: CircularProgressIndicator( - color: AppColors.background, - strokeWidth: 2.5, - ), - ) - : const Icon( - Icons.play_arrow_rounded, - color: AppColors.background, - size: 36, - ), - ), - ], - ), - ), - ); - } -} - -/// Native video embed: poster, play overlay, and an optional duration badge. -/// -/// Deliberately separate from [_VideoEmbed], which takes an [ExternalEmbed], -/// hides itself when there is no thumbnail, and gates playback on resolving a -/// Streamable URL. A native embed always carries a playable URL, and must -/// still render a frame when the AppView gave us no poster. -class _NativeVideoEmbed extends StatelessWidget { - const _NativeVideoEmbed({required this.embed}); - - final VideoPostEmbed embed; - - /// Opens the fullscreen player. Pushed synchronously — the URL is already - /// in hand, so there is nothing to resolve first. - void _play(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FullscreenVideoPlayer(videoUrl: embed.video), - fullscreenDialog: true, - ), - ); - } - - @override - Widget build(BuildContext context) { - final thumbnail = embed.thumbnail; - final duration = embed.duration; - final alt = embed.alt; - - Widget surface = AspectRatio( - aspectRatio: _defaultMediaRatio, - child: - thumbnail == null - ? const _NativeMediaFill() - : CachedNetworkImage( - imageUrl: thumbnail, - width: double.infinity, - fit: BoxFit.cover, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => const _NativeMediaFill(), - errorWidget: - (context, url, error) => - const _NativeMediaFill(icon: Icons.broken_image), - ), - ); - - if (alt != null && alt.isNotEmpty) { - surface = Semantics(image: true, label: alt, child: surface); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Semantics( - // An explicit container: without it this annotation is absorbed - // into the subtree's node, and the duration badge's text displaces - // the label. Any future overlay (mute, GIF chip) would do the same. - container: true, - explicitChildNodes: true, - button: true, - label: 'Play video', - child: GestureDetector( - key: const Key('detail-video-embed'), - onTap: () => _play(context), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - alignment: Alignment.center, - children: [ - surface, - Container( - key: const Key('detail-video-play-overlay'), - width: 64, - height: 64, - decoration: BoxDecoration( - color: AppColors.textPrimary.withValues(alpha: 0.9), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.background, - size: 36, - ), - ), - if (duration != null) - Positioned( - right: 8, - bottom: 8, - child: _NativeMediaBadge( - key: const Key('detail-video-duration-badge'), - label: formatVideoDuration(duration), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Neutral fill behind native media: shown while an image loads, when it -/// fails, and for videos the AppView gave us no thumbnail for. -class _NativeMediaFill extends StatelessWidget { - const _NativeMediaFill({this.icon}); - - final IconData? icon; - - @override - Widget build(BuildContext context) { - return ColoredBox( - color: AppColors.backgroundSecondary, - child: - icon == null - ? null - : Center(child: Icon(icon, color: AppColors.textMuted, size: 40)), - ); - } -} - -/// Small translucent pill overlaying native media — the gallery page -/// indicator and the video duration both use it. -class _NativeMediaBadge extends StatelessWidget { - const _NativeMediaBadge({required this.label, super.key}); - - final String label; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.background.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - child: Text( - label, - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - ), - ); - } } /// Content type enum for layout decisions diff --git a/lib/widgets/external_link_bar.dart b/lib/widgets/external_link_bar.dart index ad954c3..6195c76 100644 --- a/lib/widgets/external_link_bar.dart +++ b/lib/widgets/external_link_bar.dart @@ -1,9 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../constants/app_colors.dart'; import '../models/post.dart'; +import '../utils/url_display.dart'; import '../utils/url_launcher.dart'; +import 'media/favicon.dart'; /// External link bar widget for displaying clickable links /// @@ -33,7 +34,7 @@ class ExternalLinkBar extends StatelessWidget { child: Row( children: [ // Favicon - _buildFavicon(), + Favicon(embed.uri, domain: embed.domain), const SizedBox(width: 8), Expanded( child: Text( @@ -59,73 +60,16 @@ class ExternalLinkBar extends StatelessWidget { ); } - /// Extracts the domain from the embed + /// The domain to show in the bar. + /// + /// The record's own `domain` wins when it has one; otherwise the host is + /// parsed out of the uri, and a uri with no host is shown whole so the row + /// is never blank. String _extractDomain() { - // Use domain field if available - if (embed.domain != null && embed.domain!.isNotEmpty) { - return embed.domain!; + final declared = embed.domain; + if (declared != null && declared.isNotEmpty) { + return declared; } - - // Otherwise parse from URI - try { - final uri = Uri.parse(embed.uri); - if (uri.host.isNotEmpty) { - return uri.host; - } - } on FormatException { - // Invalid URI, fall through to fallback - } - - // Fallback to full URI if domain extraction fails - return embed.uri; - } - - /// Builds the favicon widget - Widget _buildFavicon() { - // Extract domain for favicon URL - var domain = embed.domain; - if (domain == null || domain.isEmpty) { - try { - final uri = Uri.parse(embed.uri); - domain = uri.host; - } on FormatException { - domain = null; - } - } - - if (domain == null || domain.isEmpty) { - // Fallback to link icon if we can't get the domain - return Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ); - } - - // Use Google's favicon service - final faviconUrl = - 'https://www.google.com/s2/favicons?domain=$domain&sz=32'; - - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: faviconUrl, - width: 18, - height: 18, - fit: BoxFit.cover, - placeholder: - (context, url) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - errorWidget: - (context, url, error) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - ), - ); + return domainOf(embed.uri) ?? embed.uri; } } diff --git a/lib/widgets/loading_error_states.dart b/lib/widgets/loading_error_states.dart index 2109557..d980bd5 100644 --- a/lib/widgets/loading_error_states.dart +++ b/lib/widgets/loading_error_states.dart @@ -93,15 +93,25 @@ class FullScreenError extends StatelessWidget { } } +/// Footprint of [InlineLoading], and of the idle spacer that stands in for +/// it in a paginated list's footer slot. +/// +/// Both sides are sized from this constant so the two are identical by +/// construction: a footer that changes height when the spinner appears +/// moves the scroll offset under the user's finger. (The spinner's +/// intrinsic size is 68px — padding plus a 36px indicator — which is why +/// "80" cannot be left implicit on either side.) +const double kInlineLoadingHeight = 80; + /// Inline loading indicator (for pagination) class InlineLoading extends StatelessWidget { const InlineLoading({super.key}); @override Widget build(BuildContext context) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16), + return const SizedBox( + height: kInlineLoadingHeight, + child: Center( child: CircularProgressIndicator(color: AppColors.primary), ), ); diff --git a/lib/widgets/media/favicon.dart b/lib/widgets/media/favicon.dart new file mode 100644 index 0000000..21611d9 --- /dev/null +++ b/lib/widgets/media/favicon.dart @@ -0,0 +1,62 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../constants/app_colors.dart'; +import '../../utils/url_display.dart'; + +/// The site icon for a link, fetched from Google's favicon service. +/// +/// Falls back to a generic link glyph whenever there is no domain to ask +/// about, or the fetch fails — every link row shows something. +class Favicon extends StatelessWidget { + const Favicon(this.url, {this.domain, this.size = 18, super.key}); + + /// The link this icon stands for. + final String url; + + /// The domain the record declared, preferred over parsing [url] when the + /// AppView supplied one. + final String? domain; + + final double size; + + String? get _domain { + final declared = domain; + if (declared != null && declared.isNotEmpty) { + return declared; + } + return domainOf(url); + } + + @override + Widget build(BuildContext context) { + final domain = _domain; + if (domain == null) { + return _fallback; + } + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + // The domain is record-supplied text, so it is passed as a query + // parameter for [Uri] to encode rather than interpolated: a value + // like `evil.com&sz=999` must stay one parameter, not become two. + imageUrl: Uri.https('www.google.com', '/s2/favicons', { + 'domain': domain, + 'sz': '32', + }).toString(), + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (context, url) => _fallback, + errorWidget: (context, url, error) => _fallback, + ), + ); + } + + Widget get _fallback => Icon( + Icons.link, + size: size, + color: AppColors.textPrimary.withValues(alpha: 0.7), + ); +} diff --git a/lib/widgets/media/media_aspect.dart b/lib/widgets/media/media_aspect.dart new file mode 100644 index 0000000..8402c44 --- /dev/null +++ b/lib/widgets/media/media_aspect.dart @@ -0,0 +1,39 @@ +import '../../models/post.dart'; + +/// Widest and tallest display ratios (width/height) a media surface accepts. +typedef MediaRatioBounds = ({double min, double max}); + +/// The feed card's bounds: 3:4 through 16:9. +/// +/// Clamping keeps a panorama from becoming a sliver and a tall portrait from +/// swallowing the viewport. The 3:4 floor lets a standard phone-camera +/// portrait through uncropped; only taller shots (9:16 screenshots, stories) +/// get center-cropped. +const MediaRatioBounds kFeedRatioBounds = (min: 3 / 4, max: 16 / 9); + +/// The detail view's bounds: 1:3 through 3:1. +/// +/// The detail view keeps far more of the true proportions than the feed card +/// does — every legitimate shape survives untouched. These bounds exist +/// purely as a safety rail: the backend never validates `aspectRatio`, so a +/// hostile record can declare 1:1000000 and, unclamped, lay out a media block +/// hundreds of millions of pixels tall. +const MediaRatioBounds kDetailRatioBounds = (min: 1 / 3, max: 3); + +/// Resolves the display ratio (width/height) for a piece of media. +/// +/// A declared [ratio] is clamped into [min]..[max]. Media with no declared +/// ratio renders at [fallback], returned as given: the fallback is a display +/// choice rather than record data, so a caller may pick a shape outside its +/// own rails. +double clampMediaRatio( + EmbedAspectRatio? ratio, { + required double min, + required double max, + double fallback = 16 / 9, +}) { + if (ratio == null) { + return fallback; + } + return (ratio.width / ratio.height).clamp(min, max); +} diff --git a/lib/widgets/media/media_format.dart b/lib/widgets/media/media_format.dart new file mode 100644 index 0000000..63cb2a1 --- /dev/null +++ b/lib/widgets/media/media_format.dart @@ -0,0 +1,18 @@ +/// Formatters shared by every media surface. +library; + +/// Formats a video duration as `m:ss`, switching to `h:mm:ss` from one hour. +/// +/// Total function: a negative duration reads as `0:00` rather than throwing, +/// since the value comes from an untrusted record. +String formatVideoDuration(int seconds) { + final total = seconds < 0 ? 0 : seconds; + final hours = total ~/ 3600; + final minutes = (total % 3600) ~/ 60; + final paddedSeconds = (total % 60).toString().padLeft(2, '0'); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds'; + } + return '$minutes:$paddedSeconds'; +} diff --git a/lib/widgets/media/media_surface.dart b/lib/widgets/media/media_surface.dart new file mode 100644 index 0000000..3779ffa --- /dev/null +++ b/lib/widgets/media/media_surface.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../constants/app_colors.dart'; + +/// Icon treatment for a [MediaFill]. The feed card and the detail view use +/// different weights because they render media at different sizes. +typedef MediaFillStyle = ({Color iconColor, double iconSize}); + +/// The feed card's fill treatment. +const MediaFillStyle kFeedMediaFill = ( + iconColor: AppColors.textSecondary, + iconSize: 32, +); + +/// The detail view's fill treatment. +const MediaFillStyle kDetailMediaFill = ( + iconColor: AppColors.textMuted, + iconSize: 40, +); + +/// Neutral fill behind media: shown while a thumbnail loads, when it fails, +/// and for videos the AppView gave us no poster for. +class MediaFill extends StatelessWidget { + const MediaFill({ + this.icon, + this.iconColor = AppColors.textSecondary, + this.iconSize = 32, + super.key, + }); + + /// Optional glyph centred in the fill. A bare fill (no icon) is what a + /// still-loading thumbnail shows, so it reads as space rather than error. + final IconData? icon; + final Color iconColor; + final double iconSize; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: AppColors.backgroundSecondary, + child: + icon == null + ? null + : Center(child: Icon(icon, color: iconColor, size: iconSize)), + ); + } +} + +/// Small translucent pill overlaying media — the image count, the gallery +/// page indicator and the video duration all use it. +class MediaBadge extends StatelessWidget { + const MediaBadge({required this.label, super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.background.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/media/native_image_embed.dart b/lib/widgets/media/native_image_embed.dart new file mode 100644 index 0000000..e637ca1 --- /dev/null +++ b/lib/widgets/media/native_image_embed.dart @@ -0,0 +1,303 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../models/post.dart'; +import 'media_aspect.dart'; +import 'media_surface.dart'; + +/// Which rendering of a hydrated image to fetch: the feed-sized `thumb` or +/// the lightbox-sized `fullsize`. +enum MediaImageSource { thumb, fullsize } + +/// A media block that may or may not be activatable. +/// +/// When [onTap] is null the button semantics are omitted entirely: a screen +/// reader announcing a button that does nothing is worse than announcing +/// nothing at all. The block keeps its [mediaKey] either way, so the surface +/// stays addressable. +class TappableMedia extends StatelessWidget { + const TappableMedia({ + required this.mediaKey, + required this.label, + required this.child, + this.onTap, + super.key, + }); + + final Key mediaKey; + final String label; + final Widget child; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final block = GestureDetector(key: mediaKey, onTap: onTap, child: child); + + if (onTap == null) { + return block; + } + + return Semantics( + // An explicit container: without it this annotation is absorbed into + // the subtree's node, swallowing the image's alt-text label. + container: true, + explicitChildNodes: true, + button: true, + label: label, + child: block, + ); + } +} + +/// The head image of a native gallery, at full block width. +/// +/// The feed card renders every images embed this way — one image, plus a +/// "1/N" badge when the gallery holds more — and the detail view uses it for +/// a single-image post. [keyPrefix] namespaces the widget keys (`post-` vs +/// `detail-`). +/// +/// [images] must be non-empty: this widget renders `images.first`. Callers +/// get that for free from `ImagesPostEmbed`, whose constructor rejects an +/// empty gallery; the assert catches any other source of the list. +class NativeImageThumb extends StatelessWidget { + const NativeImageThumb({ + required this.images, + required this.keyPrefix, + this.bounds = kFeedRatioBounds, + this.source = MediaImageSource.thumb, + this.fill = kFeedMediaFill, + this.onTap, + super.key, + }); + + final List images; + final String keyPrefix; + final MediaRatioBounds bounds; + final MediaImageSource source; + final MediaFillStyle fill; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + // Asserted here rather than in the (const) constructor, which cannot hold + // a runtime condition. + assert( + images.isNotEmpty, + 'NativeImageThumb renders images.first; ImagesPostEmbed guarantees at ' + 'least one image', + ); + final image = images.first; + + return TappableMedia( + mediaKey: Key('$keyPrefix-images-embed'), + label: 'View full image', + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + AspectRatio( + aspectRatio: clampMediaRatio( + image.aspectRatio, + min: bounds.min, + max: bounds.max, + ), + child: _NetworkMediaImage( + image: image, + source: source, + fill: fill, + ), + ), + if (images.length > 1) + Positioned( + top: 8, + right: 8, + child: MediaBadge( + key: Key('$keyPrefix-images-count-badge'), + label: '1/${images.length}', + ), + ), + ], + ), + ), + ); + } +} + +/// A swipeable carousel of fullsize native images with an i/N indicator. +/// +/// Owns its [PageController] rather than borrowing one, and rewinds when the +/// embed changes: this State is reused when the same slot renders a +/// different post, and resetting the index alone is not enough — the +/// controller would keep the previous post's page on screen while the +/// indicator and the tap target both said page one. +/// +/// [images] must be non-empty: the carousel frame is sized from +/// `images.first`. Callers get that for free from `ImagesPostEmbed`, whose +/// constructor rejects an empty gallery; the assert catches any other source +/// of the list. +class NativeImageGallery extends StatefulWidget { + const NativeImageGallery({ + required this.images, + required this.keyPrefix, + required this.onOpen, + this.bounds = kDetailRatioBounds, + this.fill = kDetailMediaFill, + super.key, + }); + + final List images; + final String keyPrefix; + + /// Called with the page the user is looking at when the gallery is tapped. + final void Function(int index) onOpen; + + final MediaRatioBounds bounds; + final MediaFillStyle fill; + + @override + State createState() => _NativeImageGalleryState(); +} + +class _NativeImageGalleryState extends State { + final PageController _controller = PageController(); + int _currentIndex = 0; + + @override + void didUpdateWidget(NativeImageGallery oldWidget) { + super.didUpdateWidget(oldWidget); + + if (_isSameGallery(oldWidget.images, widget.images)) { + return; + } + + _currentIndex = 0; + if (_controller.hasClients) { + _controller.jumpToPage(0); + } + } + + static bool _isSameGallery(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i].fullsize != b[i].fullsize) { + return false; + } + } + return true; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final images = widget.images; + // Asserted here rather than in the (const) constructor, which cannot hold + // a runtime condition. + assert( + images.isNotEmpty, + 'NativeImageGallery sizes itself from images.first; ImagesPostEmbed ' + 'guarantees at least one image', + ); + // Guard against an index left behind by a longer gallery in the frame + // before didUpdateWidget rewinds. + final current = _currentIndex < images.length ? _currentIndex : 0; + + return TappableMedia( + mediaKey: Key('${widget.keyPrefix}-images-embed'), + label: 'View full image', + onTap: () => widget.onOpen(current), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + AspectRatio( + // The gallery frame follows the first image; the rest are + // contained inside it rather than resizing the carousel. + aspectRatio: clampMediaRatio( + images.first.aspectRatio, + min: widget.bounds.min, + max: widget.bounds.max, + ), + child: PageView.builder( + controller: _controller, + itemCount: images.length, + onPageChanged: (index) { + setState(() => _currentIndex = index); + }, + itemBuilder: + (context, index) => _NetworkMediaImage( + image: images[index], + source: MediaImageSource.fullsize, + fill: widget.fill, + fit: BoxFit.contain, + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: MediaBadge( + key: Key('${widget.keyPrefix}-images-page-indicator'), + label: '${current + 1}/${images.length}', + ), + ), + ], + ), + ), + ); + } +} + +/// A single hydrated image, carrying its alt text into the semantics tree. +class _NetworkMediaImage extends StatelessWidget { + const _NetworkMediaImage({ + required this.image, + required this.source, + required this.fill, + this.fit = BoxFit.cover, + }); + + final EmbedImage image; + final MediaImageSource source; + final MediaFillStyle fill; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final alt = image.alt; + + Widget rendered = CachedNetworkImage( + imageUrl: switch (source) { + MediaImageSource.thumb => image.thumb, + MediaImageSource.fullsize => image.fullsize, + }, + width: double.infinity, + fit: fit, + // Disable fade animation to prevent scroll jitter + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: + (context, url) => + MediaFill(iconColor: fill.iconColor, iconSize: fill.iconSize), + errorWidget: + (context, url, error) => MediaFill( + icon: Icons.broken_image, + iconColor: fill.iconColor, + iconSize: fill.iconSize, + ), + ); + + if (alt != null && alt.isNotEmpty) { + rendered = Semantics(image: true, label: alt, child: rendered); + } + + return rendered; + } +} diff --git a/lib/widgets/media/native_video_embed.dart b/lib/widgets/media/native_video_embed.dart new file mode 100644 index 0000000..2c06e21 --- /dev/null +++ b/lib/widgets/media/native_video_embed.dart @@ -0,0 +1,119 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../models/post.dart'; +import '../fullscreen_video_player.dart'; +import 'media_format.dart'; +import 'media_surface.dart'; +import 'play_chip.dart'; + +/// Native video embed: poster, play overlay, and an optional duration badge. +/// +/// Deliberately separate from the Streamable embed, which takes an +/// [ExternalEmbed], hides itself when there is no thumbnail, and gates +/// playback on resolving a URL first. A native embed always carries a +/// playable URL, and must still render a frame when the AppView gave us no +/// poster. +/// +/// [keyPrefix] namespaces the widget keys (`post-` on the feed, `detail-` in +/// the detail view) so each surface stays independently addressable in tests. +class NativeVideoEmbed extends StatelessWidget { + const NativeVideoEmbed({ + required this.embed, + required this.keyPrefix, + this.aspectRatio = 16 / 9, + this.playChipStyle = PlayChipStyle.feed, + this.fill = kFeedMediaFill, + super.key, + }); + + final VideoPostEmbed embed; + final String keyPrefix; + final double aspectRatio; + final PlayChipStyle playChipStyle; + final MediaFillStyle fill; + + /// Opens the fullscreen player. Pushed synchronously — the URL is already + /// in hand, so there is nothing to resolve first. + void _play(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FullscreenVideoPlayer(videoUrl: embed.video), + fullscreenDialog: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + final thumbnail = embed.thumbnail; + final duration = embed.duration; + final alt = embed.alt; + + Widget surface = AspectRatio( + aspectRatio: aspectRatio, + child: + thumbnail == null + ? MediaFill(iconColor: fill.iconColor, iconSize: fill.iconSize) + : CachedNetworkImage( + imageUrl: thumbnail, + width: double.infinity, + fit: BoxFit.cover, + // Disable fade animation to prevent scroll jitter + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: + (context, url) => MediaFill( + iconColor: fill.iconColor, + iconSize: fill.iconSize, + ), + errorWidget: + (context, url, error) => MediaFill( + icon: Icons.broken_image, + iconColor: fill.iconColor, + iconSize: fill.iconSize, + ), + ), + ); + + if (alt != null && alt.isNotEmpty) { + surface = Semantics(image: true, label: alt, child: surface); + } + + return Semantics( + // An explicit container: without it this annotation is absorbed into + // the subtree's node, and the duration badge's text displaces the + // label. Any future overlay (mute, GIF chip) would do the same. + container: true, + explicitChildNodes: true, + button: true, + label: 'Play video', + child: GestureDetector( + key: Key('$keyPrefix-video-embed'), + onTap: () => _play(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + alignment: Alignment.center, + children: [ + surface, + PlayChip( + key: Key('$keyPrefix-video-play-overlay'), + style: playChipStyle, + ), + if (duration != null) + Positioned( + right: 8, + bottom: 8, + child: MediaBadge( + key: Key('$keyPrefix-video-duration-badge'), + label: formatVideoDuration(duration), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/media/play_chip.dart b/lib/widgets/media/play_chip.dart new file mode 100644 index 0000000..492e34f --- /dev/null +++ b/lib/widgets/media/play_chip.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../constants/app_colors.dart'; + +/// The two play-chip treatments in the app. They are deliberately inverted +/// from one another — the feed sits a light glyph on a dark disc, the detail +/// view a dark glyph on a light one — and the glyph itself is part of the +/// style, not an independent knob. +enum PlayChipStyle { feed, detail } + +/// Circular play affordance overlaying a video poster. +/// +/// Shows a spinner in place of the glyph while [loading], which is how the +/// Streamable flow reports that it is resolving a playable URL. +class PlayChip extends StatelessWidget { + const PlayChip({ + this.style = PlayChipStyle.feed, + this.loading = false, + super.key, + }); + + final PlayChipStyle style; + final bool loading; + + Color get _background => switch (style) { + PlayChipStyle.feed => AppColors.background.withValues(alpha: 0.7), + PlayChipStyle.detail => AppColors.textPrimary.withValues(alpha: 0.9), + }; + + Color get _foreground => switch (style) { + PlayChipStyle.feed => AppColors.textPrimary, + PlayChipStyle.detail => AppColors.background, + }; + + IconData get _glyph => switch (style) { + PlayChipStyle.feed => Icons.play_arrow, + PlayChipStyle.detail => Icons.play_arrow_rounded, + }; + + double get _glyphSize => switch (style) { + PlayChipStyle.feed => 48, + PlayChipStyle.detail => 36, + }; + + Widget get _spinner => switch (style) { + PlayChipStyle.feed => const CircularProgressIndicator( + color: AppColors.loadingIndicator, + ), + PlayChipStyle.detail => const Padding( + padding: EdgeInsets.all(18), + child: CircularProgressIndicator( + color: AppColors.background, + strokeWidth: 2.5, + ), + ), + }; + + @override + Widget build(BuildContext context) { + return Container( + width: 64, + height: 64, + decoration: BoxDecoration(color: _background, shape: BoxShape.circle), + child: + loading + ? _spinner + : Icon(_glyph, color: _foreground, size: _glyphSize), + ); + } +} diff --git a/lib/widgets/media/streamable_video_embed.dart b/lib/widgets/media/streamable_video_embed.dart new file mode 100644 index 0000000..89e71e0 --- /dev/null +++ b/lib/widgets/media/streamable_video_embed.dart @@ -0,0 +1,210 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../constants/app_colors.dart'; +import '../../models/post.dart'; +import '../../services/streamable_service.dart'; +import '../../utils/url_launcher.dart'; +import '../fullscreen_video_player.dart'; +import 'media_surface.dart'; +import 'play_chip.dart'; + +export 'play_chip.dart' show PlayChipStyle; + +/// Embed types the AppView uses for a playable external video. +const Set _videoEmbedTypes = {'video', 'video-stream'}; + +/// What the user is told when a Streamable URL will not resolve to an MP4. +/// One string for both surfaces. +const String _failureCopy = 'Could not load video'; + +/// A Streamable link rendered as a tappable video poster. +/// +/// Tapping resolves the MP4 behind the share URL and pushes the fullscreen +/// player. The resolve is a network round-trip, so the chip shows a spinner +/// and the button reports itself disabled while it is in flight. +/// +/// The detail view renders this widget for any video-typed embed, including +/// providers with no in-app playback. Those keep a live play chip too — the +/// tap hands the (validated) link to the external browser instead. +/// +/// The feed passes a [frameDecoration] to keep its bordered card; the detail +/// view runs full-bleed with [darken] instead. +class StreamableVideoEmbed extends StatefulWidget { + const StreamableVideoEmbed({ + required this.embed, + required this.streamableService, + this.height = 180, + this.frameDecoration, + this.darken = false, + this.playChipStyle = PlayChipStyle.feed, + super.key, + }); + + final ExternalEmbed embed; + final StreamableService streamableService; + final double height; + + /// Border/radius treatment around the poster, or null for full-bleed. + final BoxDecoration? frameDecoration; + + /// Whether to lay a scrim over the poster so the chip stays legible. + final bool darken; + + final PlayChipStyle playChipStyle; + + /// Whether [embed] is a Streamable video this widget can play. + /// + /// Case-insensitive on both fields: `embedType` and `provider` are + /// provider-supplied metadata that reaches us in whatever casing the + /// AppView recorded. The embed-type match is exact after lowercasing, not + /// a prefix test. + static bool isStreamableVideo(ExternalEmbed embed) { + final embedType = embed.embedType?.toLowerCase(); + if (embedType == null || !_videoEmbedTypes.contains(embedType)) { + return false; + } + // Only Streamable URLs can be resolved to an MP4 in-app. + return embed.provider?.toLowerCase() == 'streamable'; + } + + @override + State createState() => _StreamableVideoEmbedState(); +} + +class _StreamableVideoEmbedState extends State { + bool _isLoading = false; + + Future _play() async { + // The detail view renders this widget for any video-typed embed, so a + // non-Streamable provider can reach the tap handler with nothing to + // resolve in-app. It is still a video link, so hand it to the browser + // rather than eating the tap. UrlLauncher validates the scheme/host and + // reports its own failures. + if (!StreamableVideoEmbed.isStreamableVideo(widget.embed)) { + await UrlLauncher.launchExternalUrl(widget.embed.uri, context: context); + return; + } + + // Capture context-dependent objects before the async gap. + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + + setState(() => _isLoading = true); + + try { + final videoUrl = await widget.streamableService.getVideoUrl( + widget.embed.uri, + ); + + if (!mounted) { + return; + } + + if (videoUrl == null) { + _showFailure(messenger); + return; + } + + await navigator.push( + MaterialPageRoute( + builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl), + fullscreenDialog: true, + ), + ); + } on Object catch (error, stackTrace) { + // The service only converts DioExceptions to null; an unexpected + // response shape surfaces as a TypeError, and anything that escapes + // here would stop the spinner and tell the user nothing. + if (kDebugMode) { + debugPrint('Streamable playback failed: $error\n$stackTrace'); + } + if (mounted) { + _showFailure(messenger); + } + } finally { + // A failed resolve is retryable, so the chip always comes back. + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// The one thing the user is told when playback cannot start, whatever the + /// reason: an unresolvable URL and a malformed response read the same. + void _showFailure(ScaffoldMessengerState messenger) { + messenger.showSnackBar( + SnackBar( + content: Text( + _failureCopy, + style: GoogleFonts.inter(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.backgroundSecondary, + ), + ); + } + + @override + Widget build(BuildContext context) { + final thumb = widget.embed.thumb; + + // With no poster there is nothing to overlay a play chip on, and an + // empty frame reads as a broken card. + if (thumb == null) { + return const SizedBox.shrink(); + } + + Widget poster = CachedNetworkImage( + imageUrl: thumb, + width: double.infinity, + height: widget.height, + fit: BoxFit.cover, + // Disable fade animation to prevent scroll jitter from height changes + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => _fill(), + errorWidget: (context, url, error) => _fill(icon: Icons.broken_image), + ); + + final decoration = widget.frameDecoration; + if (decoration != null) { + poster = Container( + decoration: decoration, + clipBehavior: Clip.antiAlias, + child: poster, + ); + } + + return Semantics( + button: true, + // Reflect the dropped tap handler while the video is resolving, so + // assistive tech doesn't advertise a dead button. + enabled: !_isLoading, + label: 'Play video', + child: GestureDetector( + onTap: _isLoading ? null : _play, + child: Stack( + alignment: Alignment.center, + children: [ + poster, + if (widget.darken) + Positioned.fill( + child: ColoredBox(color: Colors.black.withValues(alpha: 0.3)), + ), + PlayChip(style: widget.playChipStyle, loading: _isLoading), + ], + ), + ), + ); + } + + Widget _fill({IconData? icon}) { + return SizedBox( + width: double.infinity, + height: widget.height, + child: MediaFill(icon: icon ?? Icons.image_outlined), + ); + } +} diff --git a/lib/widgets/paginated_sliver_list.dart b/lib/widgets/paginated_sliver_list.dart new file mode 100644 index 0000000..6568b1e --- /dev/null +++ b/lib/widgets/paginated_sliver_list.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +import 'loading_error_states.dart'; + +/// A paginated [SliverList] with the feed's anti-jitter behaviour built in. +/// +/// Every paginated surface in the app used to hand-roll this and each got a +/// different subset right. The invariants this widget carries: +/// +/// 1. **the footer slot is always reserved** while [items] is non-empty, so +/// the child count never fluctuates as pagination toggles between idle, +/// loading and error — a fluctuating child count moves the scroll offset. +/// 2. **the idle footer is exactly as tall as the spinner**: both are sized +/// from [kInlineLoadingHeight]. +/// 3. **the footer has a stable key**, so it is not rebuilt from scratch as +/// its contents change. +/// 4. **[SliverChildBuilderDelegate.findChildIndexCallback] maps item keys +/// and the footer**, which is what lets Flutter keep elements (and the +/// scroll offset) when a page is appended or prepended. +/// 5. **a failed refresh is visible**: screens gate their full-screen error +/// on an empty list, so [refreshError] carries that failure into the +/// footer when there are items on screen. +/// +/// Items are wrapped in a [RepaintBoundary] keyed by [idOf] so scrolling +/// does not repaint neighbours and element identity survives list updates. +class PaginatedSliverList extends StatelessWidget { + const PaginatedSliverList({ + required this.items, + required this.isLoadingMore, + required this.hasMore, + required this.onRetryLoadMore, + required this.idOf, + required this.itemBuilder, + this.loadMoreError, + this.refreshError, + this.onRetryRefresh, + this.endOfFeedWidget, + this.emptyWidget, + this.footerKey, + super.key, + }) : assert( + refreshError == null || onRetryRefresh != null, + 'a refreshError needs an onRetryRefresh to recover with', + ); + + /// The loaded items. + final List items; + + /// Whether the next page is in flight (footer shows a spinner). + final bool isLoadingMore; + + /// Whether another page exists (drives the end-of-feed footer). + final bool hasMore; + + /// Pagination error, shown verbatim in the footer with a retry. + final String? loadMoreError; + + /// First-page/refresh error to surface in the footer. + /// + /// Pass this only when the caller is *not* showing a full-screen error — + /// i.e. when there are items on screen. Requires [onRetryRefresh]. + final String? refreshError; + + /// Invoked by the footer's retry button while [refreshError] is showing. + final VoidCallback? onRetryRefresh; + + /// Invoked by the footer's retry button while [loadMoreError] is showing. + final VoidCallback onRetryLoadMore; + + /// Stable identity for an item — its URI, DID, or other server id. + final String Function(T item) idOf; + + /// Builds the row for an item. The [RepaintBoundary] and key are added + /// by this widget. + final Widget Function(BuildContext context, T item, int index) itemBuilder; + + /// Shown in the footer once [hasMore] is false. Without one the footer + /// stays the invisible 80px spacer. + final Widget? endOfFeedWidget; + + /// Shown instead of the list when [items] is empty. + final Widget? emptyWidget; + + /// Overrides the footer's key. Must be a `ValueKey` ending in + /// `_footer` to stay consistent with the rest of the app. + final Key? footerKey; + + Key get _footerKey => + footerKey ?? const ValueKey('paginated_list_footer'); + + @override + Widget build(BuildContext context) { + // Not a constructor assert: `endsWith` is not a constant expression, and + // this constructor stays const. + assert( + footerKey == null || + (footerKey is ValueKey && + (footerKey! as ValueKey).value.endsWith('_footer')), + 'footerKey must be a ValueKey ending in "_footer" ' + '(got $footerKey)', + ); + + if (items.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: emptyWidget ?? const SizedBox.shrink(), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == items.length) { + return KeyedSubtree(key: _footerKey, child: _buildFooter()); + } + + final item = items[index]; + return RepaintBoundary( + key: ValueKey(idOf(item)), + child: itemBuilder(context, item, index), + ); + }, + // The footer slot is reserved unconditionally: making it depend on + // isLoadingMore/loadMoreError is exactly what causes the jitter. + childCount: items.length + 1, + findChildIndexCallback: (Key key) { + if (key == _footerKey) { + return items.length; + } + if (key is! ValueKey) { + return null; + } + final index = items.indexWhere((item) => idOf(item) == key.value); + return index != -1 ? index : null; + }, + ), + ); + } + + Widget _buildFooter() { + if (isLoadingMore) { + return const InlineLoading(); + } + + final pageError = loadMoreError; + if (pageError != null) { + return InlineError(message: pageError, onRetry: onRetryLoadMore); + } + + // A failed refresh with items on screen: the caller's full-screen error + // is suppressed (it would blank content the user can still read), so + // this is the only place the failure is visible. + final refreshFailure = refreshError; + if (refreshFailure != null) { + return InlineError(message: refreshFailure, onRetry: onRetryRefresh!); + } + + final endOfFeed = endOfFeedWidget; + if (!hasMore && endOfFeed != null) { + return endOfFeed; + } + + // Idle: an invisible spacer the same height as the spinner, so the + // list geometry does not change when loading starts. + return const SizedBox(height: kInlineLoadingHeight); + } +} diff --git a/lib/widgets/post_action_bar.dart b/lib/widgets/post_action_bar.dart index 43f016a..2a4317c 100644 --- a/lib/widgets/post_action_bar.dart +++ b/lib/widgets/post_action_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../constants/app_colors.dart'; import '../models/post.dart'; -import '../utils/date_time_utils.dart'; +import '../utils/display_utils.dart'; import 'icons/animated_heart_icon.dart'; /// Post Action Bar @@ -111,7 +111,7 @@ class PostActionBar extends StatelessWidget { ), const SizedBox(width: 4), Text( - DateTimeUtils.formatCount(post.post.stats.score), + DisplayUtils.formatCount(post.post.stats.score), style: TextStyle( color: isVoted @@ -188,7 +188,7 @@ class _ActionButton extends StatelessWidget { Icon(icon, size: 24, color: effectiveColor), const SizedBox(width: 4), Text( - DateTimeUtils.formatCount(count), + DisplayUtils.formatCount(count), style: TextStyle( color: effectiveColor, fontSize: 13, diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index b85521c..b0b0342 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -10,50 +10,25 @@ import '../services/streamable_service.dart'; import '../utils/community_handle_utils.dart'; import '../utils/date_time_utils.dart'; import 'bluesky_post_card.dart'; +import 'community_avatar.dart'; import 'external_link_bar.dart'; -import 'fullscreen_video_player.dart'; import 'image_viewer.dart'; +import 'media/media_surface.dart'; +import 'media/native_image_embed.dart'; +import 'media/native_video_embed.dart'; +import 'media/streamable_video_embed.dart'; import 'post_card_actions.dart'; import 'rich_text_renderer.dart'; import 'source_link_bar.dart'; import 'tappable_author.dart'; import 'tappable_community.dart'; +import 'user_avatar.dart'; -/// Widest and tallest aspect ratios (width/height) the feed will render media -/// at. Clamping keeps a panorama from becoming a sliver and a tall portrait -/// shot from swallowing the whole viewport. The 3:4 floor lets a standard -/// phone-camera portrait through uncropped; only taller shots (9:16 -/// screenshots, stories) get center-cropped. -const double _widestMediaRatio = 16 / 9; -const double _tallestMediaRatio = 3 / 4; - -/// Resolves the display aspect ratio for an image, clamped to the feed range. -/// Media with no declared ratio renders at the 16:9 ceiling. -double _mediaAspectRatio(EmbedAspectRatio? ratio) { - if (ratio == null) { - return _widestMediaRatio; - } - return (ratio.width / ratio.height).clamp( - _tallestMediaRatio, - _widestMediaRatio, - ); -} - -/// Formats a video duration as `m:ss`, switching to `h:mm:ss` from one hour. -/// -/// Total function: a negative duration reads as `0:00` rather than throwing, -/// since the value comes from an untrusted record. -String formatVideoDuration(int seconds) { - final total = seconds < 0 ? 0 : seconds; - final hours = total ~/ 3600; - final minutes = (total % 3600) ~/ 60; - final paddedSeconds = (total % 60).toString().padLeft(2, '0'); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds'; - } - return '$minutes:$paddedSeconds'; -} +/// The feed's bordered frame around an external embed's thumbnail. +const BoxDecoration _embedFrame = BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.fromBorderSide(BorderSide(color: AppColors.border)), +); /// Post card widget for displaying feed posts /// @@ -146,7 +121,11 @@ class PostCard extends StatelessWidget { // Community avatar (tappable for community navigation) TappableCommunity( communityDid: post.post.community.did, - child: _buildCommunityAvatar(post.post.community), + child: CommunityAvatar( + name: post.post.community.name, + avatarUrl: post.post.community.avatar, + size: 24, + ), ), const SizedBox(width: 8), Expanded( @@ -247,15 +226,7 @@ class PostCard extends StatelessWidget { // Embed thumbnail if (post.post.embed?.external != null) ...[ - _EmbedCard( - embed: post.post.embed!.external!, - streamableService: context.read(), - height: embedHeight, - onImageTap: - disableNavigation - ? null - : () => _navigateToDetail(context), - ), + _buildExternalEmbed(context, post.post.embed!.external!), const SizedBox(height: 8), ], @@ -381,159 +352,43 @@ class PostCard extends StatelessWidget { /// "1/N" badge when the gallery holds more. Tapping opens the fullscreen /// viewer directly — the rest of the card still navigates to the post. Widget _buildImagesEmbed(BuildContext context, ImagesPostEmbed embed) { - final image = embed.images.first; - final alt = image.alt; - - Widget thumbnail = ClipRRect( - borderRadius: BorderRadius.circular(8), - child: AspectRatio( - aspectRatio: _mediaAspectRatio(image.aspectRatio), - child: CachedNetworkImage( - imageUrl: image.thumb, - width: double.infinity, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => const _MediaPlaceholder(), - errorWidget: - (context, url, error) => - const _MediaPlaceholder(icon: Icons.broken_image), - ), - ), - ); - - if (alt != null && alt.isNotEmpty) { - thumbnail = Semantics(image: true, label: alt, child: thumbnail); - } - - final block = GestureDetector( - key: const Key('post-images-embed'), + return NativeImageThumb( + images: embed.images, + keyPrefix: 'post', + // With navigation off there is nothing to activate, and the block + // drops its button semantics along with the handler. onTap: disableNavigation ? null : () => ImageViewer.open(context, embed.images), - child: Stack( - children: [ - thumbnail, - if (embed.images.length > 1) - Positioned( - top: 8, - right: 8, - child: _MediaBadge( - key: const Key('post-images-count-badge'), - label: '1/${embed.images.length}', - ), - ), - ], - ), - ); - - // With navigation off there is nothing to activate, and announcing a - // button a screen reader cannot use is worse than announcing nothing. - if (disableNavigation) { - return block; - } - - return Semantics( - // An explicit container: without it this annotation is absorbed into - // the subtree's node, swallowing the image's alt-text label. - container: true, - explicitChildNodes: true, - button: true, - label: 'View full image', - child: block, ); } /// Builds the video block: thumbnail or dark placeholder, a play overlay, /// and the duration when the record carried one. + /// + /// Media plays in place even when [disableNavigation] is set — it is + /// playback, not navigation. Widget _buildVideoEmbed(BuildContext context, VideoPostEmbed embed) { - final thumbnail = embed.thumbnail; - final duration = embed.duration; - final alt = embed.alt; - - Widget surface = AspectRatio( - aspectRatio: _widestMediaRatio, - child: - thumbnail != null - ? CachedNetworkImage( - imageUrl: thumbnail, - width: double.infinity, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => const _MediaPlaceholder(), - errorWidget: - (context, url, error) => - const _MediaPlaceholder(icon: Icons.broken_image), - ) - : const _MediaPlaceholder(), - ); + return NativeVideoEmbed(embed: embed, keyPrefix: 'post'); + } - if (alt != null && alt.isNotEmpty) { - surface = Semantics(image: true, label: alt, child: surface); + /// Builds the external embed block: a Streamable poster the user can play + /// in place, or a plain link thumbnail that opens the post. + Widget _buildExternalEmbed(BuildContext context, ExternalEmbed embed) { + if (StreamableVideoEmbed.isStreamableVideo(embed)) { + return StreamableVideoEmbed( + embed: embed, + streamableService: context.read(), + height: embedHeight, + frameDecoration: _embedFrame, + ); } - return Semantics( - // An explicit container: without it this annotation is absorbed into - // the subtree's node, and the duration badge's text displaces the - // label. Any future overlay (mute, GIF chip) would do the same. - container: true, - explicitChildNodes: true, - button: true, - label: 'Play video', - child: GestureDetector( - key: const Key('post-video-embed'), - onTap: () => _playVideo(context, embed), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - alignment: Alignment.center, - children: [ - surface, - Container( - key: const Key('post-video-play-overlay'), - width: 64, - height: 64, - decoration: BoxDecoration( - color: AppColors.background.withValues(alpha: 0.7), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow, - color: AppColors.textPrimary, - size: 48, - ), - ), - if (duration != null) - Positioned( - right: 8, - bottom: 8, - child: _MediaBadge( - key: const Key('post-video-duration-badge'), - label: formatVideoDuration(duration), - ), - ), - ], - ), - ), - ), - ); - } - - /// Opens the fullscreen player for a native video embed. - /// - /// Pushed synchronously: the embed already carries a playable URL, unlike - /// the Streamable flow which has to resolve one first. Media plays in place - /// even when [disableNavigation] is set — it is playback, not navigation. - void _playVideo(BuildContext context, VideoPostEmbed embed) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => FullscreenVideoPlayer(videoUrl: embed.video), - fullscreenDialog: true, - ), + return _LinkThumbnail( + embed: embed, + height: embedHeight, + onTap: disableNavigation ? null : () => _navigateToDetail(context), ); } @@ -579,52 +434,6 @@ class PostCard extends StatelessWidget { ); } - /// Builds the community avatar widget - Widget _buildCommunityAvatar(CommunityRef community) { - if (community.avatar != null && community.avatar!.isNotEmpty) { - // Show real community avatar - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: community.avatar!, - width: 24, - height: 24, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => _buildFallbackAvatar(community), - errorWidget: (context, url, error) => _buildFallbackAvatar(community), - ), - ); - } - - // Fallback to letter placeholder - return _buildFallbackAvatar(community); - } - - /// Builds a fallback avatar with the first letter of community name - Widget _buildFallbackAvatar(CommunityRef community) { - return Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - community.name[0].toUpperCase(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - /// Builds author footer with avatar, handle, and timestamp Widget _buildAuthorFooter(BuildContext context) { final author = post.post.author; @@ -640,26 +449,11 @@ class PostCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ // Author avatar (circular, small) - if (author.avatar != null && author.avatar!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: author.avatar!, - width: 20, - height: 20, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: - (context, url) => _buildAuthorFallbackAvatar(author), - errorWidget: - (context, url, error) => - _buildAuthorFallbackAvatar(author), - ), - ) - else - _buildAuthorFallbackAvatar(author), + UserAvatar( + name: author.displayName ?? author.handle, + avatarUrl: author.avatar, + size: 20, + ), const SizedBox(width: 8), // Author handle @@ -692,206 +486,44 @@ class PostCard extends StatelessWidget { ), ); } - - /// Builds a fallback avatar for the author - Widget _buildAuthorFallbackAvatar(AuthorView author) { - final firstLetter = - (author.displayName ?? author.handle).isNotEmpty - ? (author.displayName ?? author.handle)[0] - : '?'; - return Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Text( - firstLetter.toUpperCase(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } } -/// Neutral dark fill behind feed media: shown while a thumbnail loads, when -/// it fails, and for videos the AppView gave us no thumbnail for. -class _MediaPlaceholder extends StatelessWidget { - const _MediaPlaceholder({this.icon}); - - final IconData? icon; - - @override - Widget build(BuildContext context) { - return ColoredBox( - color: AppColors.backgroundSecondary, - child: - icon == null - ? null - : Center( - child: Icon(icon, color: AppColors.textSecondary, size: 32), - ), - ); - } -} - -/// Small translucent pill overlaying media — the image count and the video -/// duration both use it. -class _MediaBadge extends StatelessWidget { - const _MediaBadge({required this.label, super.key}); - - final String label; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.background.withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - child: Text( - label, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } -} - -/// Embed card widget for displaying link previews +/// A plain external-link preview: the embed's thumbnail in the feed's +/// bordered frame, opening the post when tapped. /// -/// Shows a thumbnail image for external embeds with loading and error states. -/// For video embeds (Streamable), displays a play button overlay and opens -/// a video player dialog when tapped. -class _EmbedCard extends StatefulWidget { - const _EmbedCard({ - required this.embed, - required this.streamableService, - this.height = 180, - this.onImageTap, - }); +/// Streamable videos take a different path — see [StreamableVideoEmbed], +/// which plays in place instead of navigating. +class _LinkThumbnail extends StatelessWidget { + const _LinkThumbnail({required this.embed, required this.height, this.onTap}); final ExternalEmbed embed; - final StreamableService streamableService; final double height; - final VoidCallback? onImageTap; - - @override - State<_EmbedCard> createState() => _EmbedCardState(); -} - -class _EmbedCardState extends State<_EmbedCard> { - bool _isLoadingVideo = false; - - /// Checks if this embed is a video - bool get _isVideo { - final embedType = widget.embed.embedType; - return embedType == 'video' || embedType == 'video-stream'; - } - - /// Checks if this is a Streamable video - bool get _isStreamableVideo { - return _isVideo && widget.embed.provider?.toLowerCase() == 'streamable'; - } - - /// Shows the video player in fullscreen with swipe-to-dismiss - Future _showVideoPlayer(BuildContext context) async { - // Capture context-dependent objects before async gap - final messenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - - setState(() { - _isLoadingVideo = true; - }); - - try { - // Fetch the MP4 URL from Streamable using the injected service - final videoUrl = await widget.streamableService.getVideoUrl( - widget.embed.uri, - ); - - if (!mounted) { - return; - } - - if (videoUrl == null) { - // Show error if we couldn't get the video URL - messenger.showSnackBar( - SnackBar( - content: Text( - 'Failed to load video', - style: TextStyle( - color: AppColors.textPrimary.withValues(alpha: 0.9), - ), - ), - backgroundColor: AppColors.backgroundSecondary, - ), - ); - return; - } - - // Navigate to fullscreen video player - await navigator.push( - MaterialPageRoute( - builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl), - fullscreenDialog: true, - ), - ); - } finally { - if (mounted) { - setState(() { - _isLoadingVideo = false; - }); - } - } - } + final VoidCallback? onTap; @override Widget build(BuildContext context) { - // Hide embed area when no thumbnail available - if (widget.embed.thumb == null) { + // Hide the embed area when there is no thumbnail to show. + final thumb = embed.thumb; + if (thumb == null) { return const SizedBox.shrink(); } - // Build the thumbnail image - final thumbnailWidget = Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.border), - ), + final thumbnail = Container( + decoration: _embedFrame, clipBehavior: Clip.antiAlias, child: CachedNetworkImage( - imageUrl: widget.embed.thumb!, + imageUrl: thumb, width: double.infinity, - height: widget.height, + height: height, fit: BoxFit.cover, // Disable fade animation to prevent scroll jitter from height changes fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: - (context, url) => Container( + (context, url) => SizedBox( width: double.infinity, - height: widget.height, - color: AppColors.backgroundSecondary, - child: const Center( - child: Icon( - Icons.image_outlined, - color: AppColors.textSecondary, - size: 32, - ), - ), + height: height, + child: const MediaFill(icon: Icons.image_outlined), ), errorWidget: (context, url, error) { if (kDebugMode) { @@ -900,7 +532,7 @@ class _EmbedCardState extends State<_EmbedCard> { } return Container( width: double.infinity, - height: widget.height, + height: height, color: AppColors.background, child: const Icon( Icons.broken_image, @@ -912,52 +544,10 @@ class _EmbedCardState extends State<_EmbedCard> { ), ); - // If this is a Streamable video, add play button overlay and tap handler - if (_isStreamableVideo) { - return Semantics( - button: true, - // Reflect the disabled tap handler while the video is loading so - // assistive tech doesn't advertise a dead button - enabled: !_isLoadingVideo, - label: 'Play video', - child: GestureDetector( - onTap: _isLoadingVideo ? null : () => _showVideoPlayer(context), - child: Stack( - alignment: Alignment.center, - children: [ - thumbnailWidget, - // Semi-transparent play button or loading indicator overlay - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: AppColors.background.withValues(alpha: 0.7), - shape: BoxShape.circle, - ), - child: - _isLoadingVideo - ? const CircularProgressIndicator( - color: AppColors.loadingIndicator, - ) - : const Icon( - Icons.play_arrow, - color: AppColors.textPrimary, - size: 48, - ), - ), - ], - ), - ), - ); - } - - // For non-video embeds (images, link previews), make them tappable - // to navigate to post detail - if (widget.onImageTap != null) { - return GestureDetector(onTap: widget.onImageTap, child: thumbnailWidget); + if (onTap == null) { + return thumbnail; } - // No tap handler provided, just return the thumbnail - return thumbnailWidget; + return GestureDetector(onTap: onTap, child: thumbnail); } } diff --git a/lib/widgets/post_card_actions.dart b/lib/widgets/post_card_actions.dart index b3b9947..530caea 100644 --- a/lib/widgets/post_card_actions.dart +++ b/lib/widgets/post_card_actions.dart @@ -12,8 +12,8 @@ import '../providers/community_subscription_provider.dart'; import '../providers/vote_provider.dart'; import '../services/api_exceptions.dart'; import '../services/coves_api_service.dart'; +import '../utils/display_utils.dart'; import '../utils/error_messages.dart'; -import '../utils/date_time_utils.dart'; import 'block_action_helpers.dart'; import 'icons/animated_heart_icon.dart'; import 'report_dialog.dart'; @@ -478,7 +478,7 @@ class _PostCardActionsState extends State { ), const SizedBox(width: 5), Text( - DateTimeUtils.formatCount(count), + DisplayUtils.formatCount(count), style: TextStyle( color: AppColors.textPrimary.withValues( alpha: 0.6, @@ -578,7 +578,7 @@ class _PostCardActionsState extends State { ), const SizedBox(width: 5), Text( - DateTimeUtils.formatCount(adjustedScore), + DisplayUtils.formatCount(adjustedScore), style: TextStyle( color: AppColors.textPrimary.withValues( alpha: 0.6, diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart index d477396..029ba23 100644 --- a/lib/widgets/profile_header.dart +++ b/lib/widgets/profile_header.dart @@ -7,6 +7,8 @@ import 'package:flutter/services.dart'; import '../constants/app_colors.dart'; import '../models/user_profile.dart'; import '../utils/date_time_utils.dart'; +import '../utils/display_utils.dart'; +import 'user_avatar.dart'; /// Collapsing profile header displaying the banner with the avatar and /// identity row (handle + DID) anchored to the banner's bottom edge. @@ -256,39 +258,12 @@ class ProfileHeader extends StatelessWidget { } Widget _buildAvatar(double size) { - if (profile?.avatar != null) { - return CachedNetworkImage( - imageUrl: profile!.avatar!, - width: size, - height: size, - fit: BoxFit.cover, - // Disable fade animation to prevent scroll jitter - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - // Static placeholder instead of animated spinner to prevent - // scroll jitter - placeholder: (context, url) => _buildAvatarLoading(size), - errorWidget: (context, url, error) => _buildFallbackAvatar(size), - ); - } - return _buildFallbackAvatar(size); - } - - Widget _buildAvatarLoading(double size) { - // Static placeholder instead of animated spinner to prevent scroll jitter - return Container( - width: size, - height: size, - color: AppColors.backgroundSecondary, - ); - } - - Widget _buildFallbackAvatar(double size) { - return Container( - width: size, - height: size, - color: AppColors.primary, - child: Icon(Icons.person, size: size * 0.5, color: Colors.white), + return UserAvatar( + name: profile?.handle ?? '', + avatarUrl: profile?.avatar, + size: size, + fallbackColor: AppColors.primary, + fallbackIcon: Icon(Icons.person, size: size * 0.5, color: Colors.white), ); } } @@ -493,7 +468,7 @@ class _StatItem extends StatelessWidget { @override Widget build(BuildContext context) { - final valueText = _formatNumber(value); + final valueText = DisplayUtils.formatCount(value); return RichText( text: TextSpan( @@ -517,13 +492,4 @@ class _StatItem extends StatelessWidget { ), ); } - - String _formatNumber(int value) { - if (value >= 1000000) { - return '${(value / 1000000).toStringAsFixed(1)}M'; - } else if (value >= 1000) { - return '${(value / 1000).toStringAsFixed(1)}K'; - } - return value.toString(); - } } diff --git a/lib/widgets/source_link_bar.dart b/lib/widgets/source_link_bar.dart index bd6bcb5..94f15f2 100644 --- a/lib/widgets/source_link_bar.dart +++ b/lib/widgets/source_link_bar.dart @@ -1,10 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../constants/app_colors.dart'; import '../models/post.dart'; +import '../utils/url_display.dart'; import '../utils/url_launcher.dart'; +import 'media/favicon.dart'; /// Source link bar widget for displaying clickable source links /// @@ -35,7 +35,7 @@ class SourceLinkBar extends StatelessWidget { child: Row( children: [ // Favicon - _buildFavicon(), + Favicon(source.uri, domain: source.domain), const SizedBox(width: 8), Expanded( child: Text( @@ -61,78 +61,16 @@ class SourceLinkBar extends StatelessWidget { ); } - /// Extracts the domain from the source + /// The domain to show in the bar. + /// + /// The record's own `domain` wins when it has one; otherwise the host is + /// parsed out of the uri, and a uri with no host is shown whole so the row + /// is never blank. String _extractDomain() { - // Use domain field if available - if (source.domain != null && source.domain!.isNotEmpty) { - return source.domain!; + final declared = source.domain; + if (declared != null && declared.isNotEmpty) { + return declared; } - - // Otherwise parse from URI - try { - final uri = Uri.parse(source.uri); - if (uri.host.isNotEmpty) { - return uri.host; - } - } on FormatException catch (e) { - if (kDebugMode) { - debugPrint('SourceLinkBar: Failed to parse URI "${source.uri}": $e'); - } - } - - // Fallback to full URI if domain extraction fails - return source.uri; - } - - /// Builds the favicon widget - Widget _buildFavicon() { - // Extract domain for favicon URL - var domain = source.domain; - if (domain == null || domain.isEmpty) { - try { - final uri = Uri.parse(source.uri); - domain = uri.host; - } on FormatException catch (e) { - if (kDebugMode) { - debugPrint('SourceLinkBar: Failed to parse URI "${source.uri}": $e'); - } - domain = null; - } - } - - if (domain == null || domain.isEmpty) { - // Fallback to link icon if we can't get the domain - return Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ); - } - - // Use Google's favicon service - final faviconUrl = - 'https://www.google.com/s2/favicons?domain=$domain&sz=32'; - - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: faviconUrl, - width: 18, - height: 18, - fit: BoxFit.cover, - placeholder: - (context, url) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - errorWidget: - (context, url, error) => Icon( - Icons.link, - size: 18, - color: AppColors.textPrimary.withValues(alpha: 0.7), - ), - ), - ); + return domainOf(source.uri) ?? source.uri; } } diff --git a/lib/widgets/user_avatar.dart b/lib/widgets/user_avatar.dart new file mode 100644 index 0000000..475edd4 --- /dev/null +++ b/lib/widgets/user_avatar.dart @@ -0,0 +1,127 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../constants/app_colors.dart'; +import '../utils/display_utils.dart'; + +/// Shared user/author avatar widget with CachedNetworkImage and fallback. +/// +/// The user-side counterpart to `CommunityAvatar`: +/// - Loads the avatar from [avatarUrl] via [CachedNetworkImage] +/// - Falls back to a colored circle with the first letter of [name] +/// - Always circular +/// +/// Clipping deliberately uses a [ClipRRect] of radius `size / 2` rather than +/// a `ClipOval`, so this widget contributes no `ClipOval` of its own. Callers +/// that already own one — `ProfileHeader` wraps the avatar in a `ClipOval` +/// for its border ring — stay the only `ClipOval` in the subtree, which is +/// how their tests locate the avatar. The two clips still nest; both are +/// circular and the same size, so the outer one costs nothing. +class UserAvatar extends StatelessWidget { + const UserAvatar({ + required this.name, + required this.size, + this.avatarUrl, + this.fallbackColor, + this.fallbackTextColor, + this.fallbackIcon, + this.showLoadingIndicator = false, + super.key, + }); + + /// Display name or handle, used for the fallback initial and color. + final String name; + + /// Width and height of the avatar. + final double size; + + /// Optional avatar image URL. + final String? avatarUrl; + + /// Fallback background color. Defaults to the deterministic hash color for + /// [name] so the same user looks the same everywhere. + final Color? fallbackColor; + + /// Fallback initial color. Defaults to white. + final Color? fallbackTextColor; + + /// Rendered instead of the initial when set (e.g. `Icon(Icons.person)`). + final Widget? fallbackIcon; + + /// Whether to show a loading spinner while the image loads. + final bool showLoadingIndicator; + + @override + Widget build(BuildContext context) { + final fallback = _buildFallback(); + + if (avatarUrl == null || avatarUrl!.isEmpty) { + return fallback; + } + + return ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: CachedNetworkImage( + imageUrl: avatarUrl!, + width: size, + height: size, + fit: BoxFit.cover, + // Disable fade animation to prevent scroll jitter + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => + showLoadingIndicator ? _buildLoading() : fallback, + errorWidget: (context, url, error) { + if (kDebugMode) { + debugPrint('Error loading user avatar for $name: $error'); + } + return fallback; + }, + ), + ); + } + + Widget _buildFallback() { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: fallbackColor ?? DisplayUtils.getFallbackColor(name), + shape: BoxShape.circle, + ), + child: Center( + child: fallbackIcon ?? + Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: size * 0.45, + fontWeight: FontWeight.bold, + color: fallbackTextColor ?? Colors.white, + ), + ), + ), + ); + } + + Widget _buildLoading() { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: AppColors.backgroundSecondary, + shape: BoxShape.circle, + ), + child: Center( + child: SizedBox( + width: size * 0.33, + height: size * 0.33, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ), + ); + } +} diff --git a/test/models/post_test.dart b/test/models/post_test.dart index df1a765..06b4a01 100644 --- a/test/models/post_test.dart +++ b/test/models/post_test.dart @@ -226,4 +226,70 @@ void main() { expect(copy.post, same(feedItem.post)); }); }); + + group('EmbedSource.fromJson url policy', () { + // A megathread source uri is rendered as a tappable outbound link, so it + // must satisfy the same allowlist the rest of the app enforces: an + // http/https scheme AND a non-empty host. A scheme-only uri carries an + // allowed scheme with no authority at all and must be rejected too. + test('accepts an http(s) uri with a host', () { + final source = EmbedSource.fromJson({ + 'uri': 'https://example.com/article', + 'title': 'Article', + 'domain': 'example.com', + }); + + expect(source.uri, 'https://example.com/article'); + expect(source.title, 'Article'); + expect(source.domain, 'example.com'); + }); + + test('accepts an uppercase scheme', () { + expect( + EmbedSource.fromJson({'uri': 'HTTPS://example.com'}).uri, + 'HTTPS://example.com', + ); + }); + + test('rejects a disallowed scheme', () { + for (final uri in const [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'data:text/html,

x

', + 'content://media/external/images/1', + 'httpx://evil.com', + ]) { + expect( + () => EmbedSource.fromJson({'uri': uri}), + throwsA(isA()), + reason: uri, + ); + } + }); + + test('rejects an allowed scheme with no host', () { + for (final uri in const ['https:///nohost', 'http:foo', 'http://']) { + expect( + () => EmbedSource.fromJson({'uri': uri}), + throwsA(isA()), + reason: uri, + ); + } + }); + + test('rejects a missing or empty uri', () { + expect( + () => EmbedSource.fromJson({}), + throwsA(isA()), + ); + expect( + () => EmbedSource.fromJson({'uri': ''}), + throwsA(isA()), + ); + expect( + () => EmbedSource.fromJson({'uri': 42}), + throwsA(isA()), + ); + }); + }); } diff --git a/test/providers/user_profile_provider_load_more_error_test.dart b/test/providers/user_profile_provider_load_more_error_test.dart new file mode 100644 index 0000000..eedc2c0 --- /dev/null +++ b/test/providers/user_profile_provider_load_more_error_test.dart @@ -0,0 +1,167 @@ +// RED-phase API pin for the separated load-more error channel. +// +// Compile-red until FeedState and CommentsState carry a `loadMoreError` +// field alongside `error` (the same split CursorPaginationController makes). +// Self-contained: only this file references the new field, so the rest of +// the suite keeps compiling. +// +// Pinned decision: the load-more error is exposed on the existing state +// objects the screens already read (profile_screen.dart:466/548), so the +// provider's public surface (postsState / commentsState / loadPosts / +// loadMorePosts / loadComments / loadMoreComments / retryPosts / +// retryComments) is unchanged. + +import 'package:coves_flutter/models/comment.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/models/user_profile.dart'; +import 'package:coves_flutter/providers/user_profile_provider.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_helpers/test_mocks.dart'; + +const _profileDid = 'did:plc:profileowner'; + +FeedViewPost _post(String id) { + return FeedViewPost( + post: PostView( + uri: 'at://did:plc:author/social.coves.community.post/$id', + cid: 'cid-$id', + rkey: id, + author: AuthorView(did: _profileDid, handle: 'me.test'), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + ), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: PostRecord(title: 'Post $id', content: 'body'), + stats: PostStats(upvotes: 1, downvotes: 0, score: 1, commentCount: 0), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAuthProvider mockAuthProvider; + late MockCovesApiService mockApiService; + late MockCommentService mockCommentService; + late UserProfileProvider provider; + + setUp(() async { + mockAuthProvider = MockAuthProvider(); + mockApiService = MockCovesApiService(); + mockCommentService = MockCommentService(); + + when(mockAuthProvider.isAuthenticated).thenReturn(false); + when(mockAuthProvider.did).thenReturn(null); + + provider = UserProfileProvider( + mockAuthProvider, + apiService: mockApiService, + commentService: mockCommentService, + ); + + when(mockApiService.getProfile(actor: anyNamed('actor'))).thenAnswer( + (_) async => UserProfile(did: _profileDid, handle: 'me.test'), + ); + + await provider.loadProfile(_profileDid); + }); + + tearDown(() { + provider.dispose(); + }); + + void stubPosts(List pages) { + var call = 0; + when( + mockApiService.getAuthorPosts( + actor: anyNamed('actor'), + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + final page = pages[call < pages.length ? call : pages.length - 1]; + call++; + if (page is Exception) { + throw page; + } + return page as TimelineResponse; + }); + } + + test('a load-more failure lands on postsState.loadMoreError', () async { + stubPosts([ + TimelineResponse(feed: [_post('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + + expect(provider.postsState.loadMoreError, isNotNull); + expect(provider.postsState.error, isNull); + }); + + test('a refresh clears a stale postsState.loadMoreError', () async { + stubPosts([ + TimelineResponse(feed: [_post('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + TimelineResponse(feed: [_post('a')], cursor: 'c1'), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + expect(provider.postsState.loadMoreError, isNotNull); + + await provider.loadPosts(refresh: true); + + expect(provider.postsState.loadMoreError, isNull); + }); + + test('commentsState carries its own loadMoreError', () async { + var call = 0; + when( + mockApiService.getActorComments( + actor: anyNamed('actor'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + call++; + if (call > 1) { + throw NetworkException('page 2 exploded'); + } + return ActorCommentsResponse( + comments: [ + CommentView( + uri: 'at://did:plc:author/social.coves.comment.record/a', + cid: 'cid-a', + record: const CommentRecord(content: 'Test comment content'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + author: AuthorView(did: _profileDid, handle: 'me.test'), + post: CommentRef( + uri: 'at://did:plc:test/social.coves.post.record/123', + cid: 'post-cid', + ), + stats: const CommentStats(score: 1, upvotes: 1), + ), + ], + cursor: 'c1', + ); + }); + + await provider.loadComments(refresh: true); + await provider.loadMoreComments(); + + expect(provider.commentsState.loadMoreError, isNotNull); + expect(provider.commentsState.error, isNull); + }); +} diff --git a/test/providers/user_profile_provider_pagination_test.dart b/test/providers/user_profile_provider_pagination_test.dart new file mode 100644 index 0000000..e76454c --- /dev/null +++ b/test/providers/user_profile_provider_pagination_test.dart @@ -0,0 +1,500 @@ +// RED-phase behavioural tests for UserProfileProvider pagination. +// +// The provider currently funnels first-page and load-more failures into the +// SAME FeedState.error / CommentsState.error field (see loadPosts :210-329 +// and loadComments :340-458). profile_screen.dart then has to disambiguate +// by checking `posts.isEmpty` (:478) and re-uses the same string for the +// footer error (:518), so a pagination hiccup poisons the first-page error +// channel. +// +// Target behaviour: a load-more failure is reported on its own channel and +// never touches the first-page error. The load-more channel itself is pinned +// in user_profile_provider_load_more_error_test.dart (compile-red). +// +// This file compiles against today's API on purpose, so the failures are +// real behavioural failures rather than analyzer errors. + +import 'package:coves_flutter/models/comment.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/models/user_profile.dart'; +import 'package:coves_flutter/providers/user_profile_provider.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_helpers/test_mocks.dart'; + +const profileDid = 'did:plc:profileowner'; + +FeedViewPost buildPost(String id) { + return FeedViewPost( + post: PostView( + uri: 'at://did:plc:author/social.coves.community.post/$id', + cid: 'cid-$id', + rkey: id, + author: AuthorView(did: profileDid, handle: 'me.test'), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + ), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: PostRecord(title: 'Post $id', content: 'body'), + stats: PostStats(upvotes: 1, downvotes: 0, score: 1, commentCount: 0), + ), + ); +} + +CommentView buildComment(String id) { + return CommentView( + uri: 'at://did:plc:author/social.coves.comment.record/$id', + cid: 'cid-$id', + record: const CommentRecord(content: 'Test comment content'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + author: AuthorView(did: profileDid, handle: 'me.test'), + post: CommentRef( + uri: 'at://did:plc:test/social.coves.post.record/123', + cid: 'post-cid', + ), + stats: const CommentStats(score: 1, upvotes: 1), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAuthProvider mockAuthProvider; + late MockCovesApiService mockApiService; + late MockCommentService mockCommentService; + late UserProfileProvider provider; + + setUp(() async { + mockAuthProvider = MockAuthProvider(); + mockApiService = MockCovesApiService(); + mockCommentService = MockCommentService(); + + when(mockAuthProvider.isAuthenticated).thenReturn(false); + when(mockAuthProvider.did).thenReturn(null); + + provider = UserProfileProvider( + mockAuthProvider, + apiService: mockApiService, + commentService: mockCommentService, + ); + + when(mockApiService.getProfile(actor: anyNamed('actor'))).thenAnswer( + (_) async => UserProfile(did: profileDid, handle: 'me.test'), + ); + + await provider.loadProfile(profileDid); + }); + + tearDown(() { + provider.dispose(); + }); + + /// Answers getAuthorPosts with [pages] in order; a page may be an + /// exception to throw instead. + void stubPosts(List pages) { + var call = 0; + when( + mockApiService.getAuthorPosts( + actor: anyNamed('actor'), + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + final page = pages[call < pages.length ? call : pages.length - 1]; + call++; + if (page is Exception) { + throw page; + } + return page as TimelineResponse; + }); + } + + void stubComments(List pages) { + var call = 0; + when( + mockApiService.getActorComments( + actor: anyNamed('actor'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + final page = pages[call < pages.length ? call : pages.length - 1]; + call++; + if (page is Exception) { + throw page; + } + return page as ActorCommentsResponse; + }); + } + + group('posts pagination', () { + test('a load-more failure does not populate the first-page error', + () async { + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + ]); + + await provider.loadPosts(refresh: true); + expect(provider.postsState.error, isNull); + + await provider.loadMorePosts(); + + // The full-screen error channel must stay clean: only the first page + // failing is a full-screen condition. + expect(provider.postsState.error, isNull); + }); + + test('a load-more failure keeps the loaded posts, cursor and hasMore', + () async { + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + + expect(provider.postsState.posts, hasLength(1)); + expect(provider.postsState.cursor, 'c1'); + expect(provider.postsState.hasMore, isTrue); + expect(provider.postsState.isLoadingMore, isFalse); + expect(provider.postsState.isLoading, isFalse); + }); + + // SPEC CHANGE (multi-model review, FIX 6): plain loadMorePosts() no + // longer resumes after a failure — the scroll trigger keeps calling it + // while the user sits at the bottom, which retried a failing page ~10 + // times a second. Resuming is now an explicit user action. + test('a load-more retry after a failure still appends', () async { + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + TimelineResponse(feed: [buildPost('b')], cursor: 'c2'), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + await provider.retryLoadMorePosts(); + + expect(provider.postsState.posts, hasLength(2)); + expect(provider.postsState.error, isNull); + expect(provider.postsState.loadMoreError, isNull); + }); + + test('the scroll trigger cannot re-fire a failed page', () async { + var requests = 0; + when( + mockApiService.getAuthorPosts( + actor: anyNamed('actor'), + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + requests++; + if (requests == 1) { + return TimelineResponse( + feed: [buildPost('a')], + cursor: 'c1', + ); + } + throw NetworkException('offline'); + }); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + await provider.loadMorePosts(); + await provider.loadMorePosts(); + + expect(requests, 2); + expect(provider.postsState.loadMoreError, isNotNull); + }); + + test('an overlapping page is not appended twice', () async { + // Cursor drift on the server: page 2 repeats a post from page 1. The + // list keys rows by post URI, so a duplicate is an assertion crash. + stubPosts([ + TimelineResponse( + feed: [buildPost('a'), buildPost('b')], + cursor: 'c1', + ), + TimelineResponse( + feed: [buildPost('b'), buildPost('c')], + cursor: 'c2', + ), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + + expect( + provider.postsState.posts.map((p) => p.post.uri).toList(), + hasLength(3), + ); + expect( + provider.postsState.posts.map((p) => p.post.uri).toSet(), + hasLength(3), + ); + }); + + test('a first-page failure still populates the full-screen error', + () async { + stubPosts([NetworkException('first page exploded')]); + + await provider.loadPosts(refresh: true); + + expect(provider.postsState.error, isNotNull); + expect(provider.postsState.posts, isEmpty); + expect(provider.postsState.isLoading, isFalse); + }); + + test('a refresh after a load-more failure leaves no stale error', + () async { + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + NetworkException('page 2 exploded'), + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + ]); + + await provider.loadPosts(refresh: true); + await provider.loadMorePosts(); + await provider.loadPosts(refresh: true); + + expect(provider.postsState.error, isNull); + }); + }); + + group('vote hydration', () { + late MockVoteProvider mockVoteProvider; + late UserProfileProvider authedProvider; + + setUp(() async { + mockVoteProvider = MockVoteProvider(); + final authedAuthProvider = MockAuthProvider(); + when(authedAuthProvider.isAuthenticated).thenReturn(true); + when(authedAuthProvider.did).thenReturn(profileDid); + + authedProvider = UserProfileProvider( + authedAuthProvider, + apiService: mockApiService, + commentService: mockCommentService, + voteProvider: mockVoteProvider, + ); + + await authedProvider.loadProfile(profileDid); + }); + + tearDown(() { + authedProvider.dispose(); + }); + + test('seeds viewer vote state once per post, page by page', () async { + // The profile is often a post's first surface this session, so a + // liked post must show a lit heart. Re-seeding page 1 on every + // append is how double-counted scores have happened before. + stubPosts([ + TimelineResponse( + feed: [buildPost('a'), buildPost('b')], + cursor: 'c1', + ), + TimelineResponse(feed: [buildPost('c')], cursor: 'c2'), + ]); + + await authedProvider.loadPosts(refresh: true); + + verify( + mockVoteProvider.applyServerVoteState( + postUri: buildPost('a').post.uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ).called(1); + + await authedProvider.loadMorePosts(); + + // Page 2 seeds only page 2. + verify( + mockVoteProvider.applyServerVoteState( + postUri: buildPost('c').post.uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ).called(1); + verifyNever( + mockVoteProvider.applyServerVoteState( + postUri: buildPost('a').post.uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ); + }); + + test('a duplicated post on page 2 is not re-seeded', () async { + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + TimelineResponse( + feed: [buildPost('a'), buildPost('b')], + cursor: 'c2', + ), + ]); + + await authedProvider.loadPosts(refresh: true); + clearInteractions(mockVoteProvider); + await authedProvider.loadMorePosts(); + + verifyNever( + mockVoteProvider.applyServerVoteState( + postUri: buildPost('a').post.uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ); + verify( + mockVoteProvider.applyServerVoteState( + postUri: buildPost('b').post.uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ).called(1); + }); + + test('seeds viewer vote state for comments too', () async { + stubComments([ + ActorCommentsResponse( + comments: [buildComment('a')], + cursor: 'c1', + ), + ]); + + await authedProvider.loadComments(refresh: true); + + verify( + mockVoteProvider.applyServerVoteState( + postUri: buildComment('a').uri, + voteDirection: anyNamed('voteDirection'), + voteUri: anyNamed('voteUri'), + ), + ).called(1); + }); + }); + + group('deleting a comment', () { + test('removes it from the loaded comments and notifies', () async { + stubComments([ + ActorCommentsResponse( + comments: [buildComment('a'), buildComment('b')], + cursor: 'c1', + ), + ]); + when( + mockCommentService.deleteComment(uri: anyNamed('uri')), + ).thenAnswer((_) async {}); + + await provider.loadComments(refresh: true); + + var notifications = 0; + provider.addListener(() => notifications++); + + await provider.deleteComment(commentUri: buildComment('a').uri); + + expect( + provider.commentsState.comments.map((c) => c.uri), + [buildComment('b').uri], + ); + expect(notifications, greaterThanOrEqualTo(1)); + // Page boundaries on the server did not move. + expect(provider.commentsState.cursor, 'c1'); + expect(provider.commentsState.hasMore, isTrue); + }); + + test('a failed delete leaves the list alone and rethrows', () async { + stubComments([ + ActorCommentsResponse( + comments: [buildComment('a')], + cursor: 'c1', + ), + ]); + when( + mockCommentService.deleteComment(uri: anyNamed('uri')), + ).thenThrow(ApiException('forbidden')); + + await provider.loadComments(refresh: true); + + await expectLater( + provider.deleteComment(commentUri: buildComment('a').uri), + throwsA(isA()), + ); + expect(provider.commentsState.comments, hasLength(1)); + }); + }); + + group('a failed refresh with posts on screen', () { + test('keeps the posts and reports on the first-page channel', () async { + // profile_screen only shows the full-screen error when the list is + // empty, and surfaces this in the list footer otherwise. + stubPosts([ + TimelineResponse(feed: [buildPost('a')], cursor: 'c1'), + NetworkException('refresh exploded'), + ]); + + await provider.loadPosts(refresh: true); + final firstRefreshTime = provider.postsState.lastRefreshTime; + await provider.loadPosts(refresh: true); + + expect(provider.postsState.posts, hasLength(1)); + expect(provider.postsState.cursor, 'c1'); + expect(provider.postsState.error, isNotNull); + expect(provider.postsState.isLoading, isFalse); + // A refresh that never landed must not move "last refreshed". + expect(provider.postsState.lastRefreshTime, firstRefreshTime); + }); + }); + + group('comments pagination', () { + test('a load-more failure does not populate the first-page error', + () async { + stubComments([ + ActorCommentsResponse( + comments: [buildComment('a')], + cursor: 'c1', + ), + NetworkException('page 2 exploded'), + ]); + + await provider.loadComments(refresh: true); + expect(provider.commentsState.error, isNull); + + await provider.loadMoreComments(); + + expect(provider.commentsState.error, isNull); + }); + + test('a load-more failure keeps the loaded comments and cursor', + () async { + stubComments([ + ActorCommentsResponse( + comments: [buildComment('a')], + cursor: 'c1', + ), + NetworkException('page 2 exploded'), + ]); + + await provider.loadComments(refresh: true); + await provider.loadMoreComments(); + + expect(provider.commentsState.comments, hasLength(1)); + expect(provider.commentsState.cursor, 'c1'); + expect(provider.commentsState.isLoadingMore, isFalse); + }); + }); +} diff --git a/test/screens/communities_see_all_screen_test.dart b/test/screens/communities_see_all_screen_test.dart new file mode 100644 index 0000000..779f516 --- /dev/null +++ b/test/screens/communities_see_all_screen_test.dart @@ -0,0 +1,166 @@ +// Regression tests for the shared pagination wiring, driven through a real +// adopting screen. +// +// CommunitiesSeeAllScreen is the cheapest real surface that uses +// CursorPaginationController + PaginationScrollListener + +// PaginatedSliverList together: its only injected dependency is the API +// client. +// +// The behaviour under test is FIX 3 from the multi-model review: a first +// page too short to fill the viewport leaves nothing to scroll, so a +// scroll-event-driven trigger would stall there forever. The profile +// screen's old build-phase trigger covered this by accident; the shared +// listener has to be asked explicitly. + +import 'package:coves_flutter/models/community.dart'; +import 'package:coves_flutter/screens/home/communities_see_all_screen.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:coves_flutter/services/coves_api_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/test_mocks.dart'; + +CommunityView community(String id) { + return CommunityView( + did: 'did:plc:$id', + name: id, + displayName: 'Community $id', + ); +} + +CommunitiesResponse pageOf(List ids, {String? cursor}) { + return CommunitiesResponse( + communities: ids.map(community).toList(), + cursor: cursor, + ); +} + +void main() { + late MockCovesApiService mockApiService; + + setUp(() { + mockApiService = MockCovesApiService(); + }); + + /// Answers listCommunities with [pages] in order, and records how many + /// requests were made. + List stubPages(List pages) { + final requests = [0]; + when( + mockApiService.listCommunities( + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + sort: anyNamed('sort'), + subscribed: anyNamed('subscribed'), + ), + ).thenAnswer((_) async { + final index = requests[0]; + requests[0]++; + final page = pages[index < pages.length ? index : pages.length - 1]; + if (page is Exception) { + throw page; + } + return page as CommunitiesResponse; + }); + return requests; + } + + Future pumpScreen(WidgetTester tester) async { + await tester.pumpWidget( + Provider.value( + value: mockApiService, + child: const MaterialApp( + home: CommunitiesSeeAllScreen(title: 'All', sort: 'popular'), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('a first page too short to scroll still loads the next one', ( + tester, + ) async { + final requests = stubPages([ + pageOf(['a', 'b', 'c'], cursor: 'c1'), + pageOf(['d', 'e', 'f']), + ]); + + await pumpScreen(tester); + + expect(requests[0], 2); + expect(find.text('Community a'), findsOneWidget); + expect(find.text('Community f'), findsOneWidget); + }); + + testWidgets('it stops once the server stops handing back a cursor', ( + tester, + ) async { + final requests = stubPages([pageOf(['a'])]); + + await pumpScreen(tester); + + expect(requests[0], 1); + }); + + testWidgets('an empty page with a cursor does not poll forever', ( + tester, + ) async { + // The server keeps offering a cursor for a page it never fills. + final requests = stubPages([ + pageOf(['a'], cursor: 'c1'), + pageOf(const [], cursor: 'c2'), + ]); + + await pumpScreen(tester); + + expect(requests[0], 2); + }); + + testWidgets('a pull-to-refresh failure is visible with rows on screen', ( + tester, + ) async { + // The full-screen error is gated on an empty list, so before this the + // user saw nothing at all: the list just sat there unchanged. + stubPages([ + pageOf(['a']), + NetworkException('the refresh exploded'), + ]); + + await pumpScreen(tester); + expect(find.text('Community a'), findsOneWidget); + + await tester.fling( + find.byType(CustomScrollView), + const Offset(0, 300), + 1000, + ); + await tester.pumpAndSettle(); + + expect(find.text('the refresh exploded'), findsOneWidget); + // ...without blanking the rows that are still perfectly readable. + expect(find.text('Community a'), findsOneWidget); + }); + + testWidgets('a failed page is not retried automatically', (tester) async { + // The scroll/viewport trigger would otherwise re-fire on every tick. + final requests = stubPages([ + pageOf(['a', 'b'], cursor: 'c1'), + NetworkException('offline'), + ]); + + await pumpScreen(tester); + + expect(requests[0], 2); + // The screen's errorMapper passes a typed ApiException's message through. + expect(find.text('offline'), findsOneWidget); + + // ...and the footer's Retry resumes it. + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(requests[0], greaterThan(2)); + }); +} diff --git a/test/screens/create_post_screen_test.dart b/test/screens/create_post_screen_test.dart index 097638c..d57b9b7 100644 --- a/test/screens/create_post_screen_test.dart +++ b/test/screens/create_post_screen_test.dart @@ -1,10 +1,15 @@ import 'package:coves_flutter/models/community.dart'; import 'package:coves_flutter/providers/auth_provider.dart'; import 'package:coves_flutter/screens/home/create_post_screen.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:coves_flutter/services/coves_api_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/test_mocks.dart'; + // Fake AuthProvider for testing class FakeAuthProvider extends AuthProvider { bool _isAuthenticated = true; @@ -384,4 +389,143 @@ void main() { expect(find.text('This is my post content'), findsOneWidget); }); }); + + group('CreatePostScreen URL validation', () { + // The composer's URL field is the only place a user-typed uri becomes an + // ExternalEmbedInput posted to the backend. It must enforce the same + // allowlist as every other url check in the app: an http/https scheme + // (exact, not "starts with http") AND a non-empty host. + late FakeAuthProvider fakeAuthProvider; + late MockCovesApiService mockApiService; + + const kInvalidUrlMessage = 'Please enter a valid URL (http or https)'; + + setUp(() { + fakeAuthProvider = FakeAuthProvider(); + mockApiService = MockCovesApiService(); + + when( + mockApiService.listCommunities( + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + sort: anyNamed('sort'), + subscribed: anyNamed('subscribed'), + ), + ).thenAnswer( + (_) async => CommunitiesResponse( + communities: [ + CommunityView( + did: 'did:plc:community', + name: 'testcove', + displayName: 'Test Cove', + ), + ], + ), + ); + + // Any createPost that does happen is short-circuited so the screen + // never navigates to PostDetailScreen; the assertion of interest is + // whether it was called at all. + when( + mockApiService.createPost( + community: anyNamed('community'), + title: anyNamed('title'), + content: anyNamed('content'), + facets: anyNamed('facets'), + embed: anyNamed('embed'), + langs: anyNamed('langs'), + labels: anyNamed('labels'), + ), + ).thenThrow(ApiException('stubbed - should not have been called')); + }); + + Widget createTestWidget() { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: fakeAuthProvider), + Provider.value(value: mockApiService), + ], + child: const MaterialApp(home: CreatePostScreen()), + ); + } + + /// Opens the community picker and selects the single stubbed community, + /// which is the only way to make the form (and the Post button) valid. + Future selectCommunity(WidgetTester tester) async { + await tester.tap(find.text('Select a community')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Test Cove')); + await tester.pumpAndSettle(); + } + + void verifyCreatePostNeverCalled() { + verifyNever( + mockApiService.createPost( + community: anyNamed('community'), + title: anyNamed('title'), + content: anyNamed('content'), + facets: anyNamed('facets'), + embed: anyNamed('embed'), + langs: anyNamed('langs'), + labels: anyNamed('labels'), + ), + ); + } + + Future submitWithUrl(WidgetTester tester, String url) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await selectCommunity(tester); + + await tester.enterText(find.widgetWithText(TextField, 'URL'), url); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Post')); + await tester.pumpAndSettle(); + } + + testWidgets('rejects a scheme that merely starts with http', ( + tester, + ) async { + await submitWithUrl(tester, 'httpx://evil.com'); + + expect(find.text(kInvalidUrlMessage), findsOneWidget); + verifyCreatePostNeverCalled(); + }); + + testWidgets('rejects an https scheme with no host', (tester) async { + await submitWithUrl(tester, 'https:///nohost'); + + expect(find.text(kInvalidUrlMessage), findsOneWidget); + verifyCreatePostNeverCalled(); + }); + + testWidgets('rejects a non-web scheme', (tester) async { + await submitWithUrl(tester, 'javascript:alert(1)'); + + expect(find.text(kInvalidUrlMessage), findsOneWidget); + verifyCreatePostNeverCalled(); + }); + + testWidgets('accepts a well-formed https url', (tester) async { + await submitWithUrl(tester, 'https://example.com/article'); + + // Positive control: the harness really can reach _handleSubmit, so a + // failing rejection test above is the validator's fault, not the + // fixture's. + expect(find.text(kInvalidUrlMessage), findsNothing); + verify( + mockApiService.createPost( + community: anyNamed('community'), + title: anyNamed('title'), + content: anyNamed('content'), + facets: anyNamed('facets'), + embed: anyNamed('embed'), + langs: anyNamed('langs'), + labels: anyNamed('labels'), + ), + ).called(1); + }); + }); } diff --git a/test/utils/cursor_pagination_controller_test.dart b/test/utils/cursor_pagination_controller_test.dart new file mode 100644 index 0000000..b97039f --- /dev/null +++ b/test/utils/cursor_pagination_controller_test.dart @@ -0,0 +1,1011 @@ +// RED-phase spec for the shared cursor pagination controller. +// +// This file is intentionally compile-red until +// lib/utils/cursor_pagination_controller.dart exists. It is self-contained: +// nothing else in the suite imports it, so the rest of the tests still +// compile while this one fails to resolve its import. +// +// The spec pins the behaviours that are currently inconsistent across +// community_feed_screen.dart, communities_see_all_screen.dart and +// user_profile_provider.dart: +// - single-flight guards +// - a refresh started while a loadMore is in flight discards the stale page +// (community feed sort-change race) +// - refresh clears BOTH error and loadMoreError (community feed stale +// load-more error surviving a refresh / sort change) +// - loadMore failures never touch the first-page `error` (profile's +// shared-error-field bug) +// - loading flags are always cleared, even when the fetch throws +// - appends produce new list instances (no in-place addAll) + +import 'dart:async'; + +import 'package:coves_flutter/utils/cursor_pagination_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A fetch function whose pages are completed by hand, so every test can +/// interleave refresh/loadMore deterministically without timers. +class _ScriptedFetcher { + final List requestedCursors = []; + final List>> completers = + >>[]; + + int get requestCount => completers.length; + + Future> call(String? cursor) { + requestedCursors.add(cursor); + final completer = Completer>(); + completers.add(completer); + return completer.future; + } + + void complete(int index, CursorPage page) { + completers[index].complete(page); + } + + void fail(int index, Object error) { + completers[index].completeError(error); + } +} + +/// Stand-in for a transport failure (the tests only care that it is thrown). +class NetworkFailure implements Exception { + @override + String toString() => 'NetworkFailure: the network is gone'; +} + +CursorPage page(List items, {String? cursor}) { + return CursorPage(items: items, cursor: cursor); +} + +void main() { + late _ScriptedFetcher fetcher; + + setUp(() { + fetcher = _ScriptedFetcher(); + }); + + CursorPaginationController build({ + Future Function(List newItems)? onPageLoaded, + String Function(Object error)? errorMapper, + String Function(String item)? idOf, + void Function(Object error, StackTrace stack)? onUnexpectedError, + }) { + return CursorPaginationController( + fetchPage: fetcher.call, + onPageLoaded: onPageLoaded, + errorMapper: errorMapper, + idOf: idOf, + onUnexpectedError: onUnexpectedError, + ); + } + + /// Drives the first page to completion. + Future loadFirstPage( + CursorPaginationController controller, { + List items = const ['a', 'b'], + String? cursor = 'cursor-1', + int requestIndex = 0, + }) async { + final future = controller.refresh(); + fetcher.complete(requestIndex, page(items, cursor: cursor)); + await future; + } + + group('first load', () { + test('starts empty and idle', () { + final controller = build(); + addTearDown(controller.dispose); + + expect(controller.items, isEmpty); + expect(controller.cursor, isNull); + expect(controller.isLoading, isFalse); + expect(controller.isLoadingMore, isFalse); + expect(controller.error, isNull); + expect(controller.loadMoreError, isNull); + // Nothing has been fetched yet, so there is no cursor to follow. + expect(controller.hasMore, isFalse); + }); + + test('refresh fetches with a null cursor and exposes the page', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + expect(fetcher.requestedCursors, [null]); + expect(controller.items, ['a', 'b']); + expect(controller.cursor, 'cursor-1'); + expect(controller.hasMore, isTrue); + expect(controller.isLoading, isFalse); + expect(controller.error, isNull); + }); + + test('isLoading is true only while the first page is in flight', () async { + final controller = build(); + addTearDown(controller.dispose); + + final future = controller.refresh(); + expect(controller.isLoading, isTrue); + expect(controller.isLoadingMore, isFalse); + + fetcher.complete(0, page(['a'], cursor: 'c1')); + await future; + + expect(controller.isLoading, isFalse); + }); + + test('notifies listeners when the page arrives', () async { + final controller = build(); + addTearDown(controller.dispose); + + var notifications = 0; + controller.addListener(() => notifications++); + + await loadFirstPage(controller); + + expect(notifications, greaterThanOrEqualTo(2)); + }); + }); + + group('append', () { + test('loadMore sends the current cursor and appends the page', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final future = controller.loadMore(); + expect(controller.isLoadingMore, isTrue); + fetcher.complete(1, page(['c'], cursor: 'cursor-2')); + await future; + + expect(fetcher.requestedCursors, [null, 'cursor-1']); + expect(controller.items, ['a', 'b', 'c']); + expect(controller.cursor, 'cursor-2'); + expect(controller.isLoadingMore, isFalse); + }); + + test('append produces a new list instance and never mutates the old ' + 'one', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + final before = controller.items; + + final future = controller.loadMore(); + fetcher.complete(1, page(['c'], cursor: 'cursor-2')); + await future; + + expect(identical(before, controller.items), isFalse); + expect(before, ['a', 'b']); + }); + + test('refresh replaces the items instead of appending', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final future = controller.refresh(); + fetcher.complete(1, page(['z'], cursor: 'cursor-9')); + await future; + + expect(controller.items, ['z']); + expect(fetcher.requestedCursors, [null, null]); + }); + }); + + group('hasMore', () { + test('a null cursor ends the feed', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller, cursor: null); + + expect(controller.hasMore, isFalse); + }); + + test('an empty-string cursor ends the feed', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller, cursor: ''); + + expect(controller.hasMore, isFalse); + }); + + test('loadMore is a no-op once the feed has ended', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller, cursor: null); + + await controller.loadMore(); + + expect(fetcher.requestCount, 1); + expect(controller.isLoadingMore, isFalse); + }); + }); + + group('single flight', () { + // SPEC CHANGE (multi-model review, FIX 2): this used to pin "a second + // refresh while one is in flight is ignored". Dropping it meant a sort + // change (or profile switch) during the initial load rendered the OLD + // query's content under the NEW label, because the generation counter + // only covers a refresh landing on top of a loadMore. A refresh now + // supersedes whatever is in flight, refresh included. + test('a second refresh supersedes the one in flight', () async { + final controller = build(); + addTearDown(controller.dispose); + + final first = controller.refresh(); + final second = controller.refresh(); + + expect(fetcher.requestCount, 2); + + // The superseded request lands first and must be thrown away. + fetcher.complete(0, page(['stale'], cursor: 'stale-cursor')); + expect(await first, isFalse); + expect(controller.items, isEmpty); + expect(controller.isLoading, isTrue); + + fetcher.complete(1, page(['fresh'], cursor: 'c1')); + expect(await second, isTrue); + + expect(controller.items, ['fresh']); + expect(controller.cursor, 'c1'); + expect(controller.isLoading, isFalse); + }); + + test('a superseding refresh sees the new query context', () async { + // The community-feed sort race and the profile-switch race: the + // fetcher closes over screen state that changed between the two + // calls, so only the second call's page may reach the controller. + var sort = 'hot'; + final controller = CursorPaginationController( + fetchPage: (cursor) => fetcher.call(sort), + ); + addTearDown(controller.dispose); + + final hotLoad = controller.refresh(); + sort = 'new'; + final newLoad = controller.refresh(); + + expect(fetcher.requestedCursors, ['hot', 'new']); + + fetcher.complete(1, page(['new-1'], cursor: 'c-new')); + expect(await newLoad, isTrue); + + fetcher.complete(0, page(['hot-1'], cursor: 'c-hot')); + expect(await hotLoad, isFalse); + + expect(controller.items, ['new-1']); + expect(controller.cursor, 'c-new'); + expect(controller.isLoading, isFalse); + }); + + test('a superseded refresh failure never surfaces', () async { + final controller = build(); + addTearDown(controller.dispose); + + final first = controller.refresh(); + final second = controller.refresh(); + + fetcher.fail(0, Exception('superseded boom')); + expect(await first, isFalse); + + fetcher.complete(1, page(['a'], cursor: 'c1')); + await second; + + expect(controller.error, isNull); + expect(controller.items, ['a']); + }); + + test('a second loadMore while one is in flight is ignored', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final first = controller.loadMore(); + final second = controller.loadMore(); + + expect(fetcher.requestCount, 2); + + fetcher.complete(1, page(['c'], cursor: 'cursor-2')); + await Future.wait(>[first, second]); + + expect(controller.items, ['a', 'b', 'c']); + }); + + test('loadMore during a first-page load is ignored', () async { + final controller = build(); + addTearDown(controller.dispose); + + final refreshFuture = controller.refresh(); + final loadMoreFuture = controller.loadMore(); + + expect(fetcher.requestCount, 1); + + fetcher.complete(0, page(['a'], cursor: 'c1')); + await Future.wait(>[refreshFuture, loadMoreFuture]); + + expect(controller.items, ['a']); + }); + }); + + group('generation counter', () { + test('a refresh started during a loadMore discards the stale page', + () async { + // This is the community-feed sort-change race: the user changes sort + // while a page of the previous sort is still in flight. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final staleLoadMore = controller.loadMore(); // request 1 + final refreshed = controller.refresh(); // request 2 — must be allowed + + expect(fetcher.requestCount, 3); + + fetcher.complete(2, page(['x'], cursor: 'cursor-new')); + await refreshed; + + expect(controller.items, ['x']); + + // The stale page lands last and must be thrown away. + fetcher.complete(1, page(['stale'], cursor: 'cursor-stale')); + await staleLoadMore; + + expect(controller.items, ['x']); + expect(controller.cursor, 'cursor-new'); + expect(controller.isLoadingMore, isFalse); + }); + + test('a discarded stale page never reaches onPageLoaded', () async { + final hydrated = >[]; + final controller = build( + onPageLoaded: (newItems) async => hydrated.add(newItems), + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final staleLoadMore = controller.loadMore(); + final refreshed = controller.refresh(); + + fetcher.complete(2, page(['x'], cursor: 'cursor-new')); + await refreshed; + fetcher.complete(1, page(['stale'], cursor: 'cursor-stale')); + await staleLoadMore; + + expect( + hydrated, + >[ + ['a', 'b'], + ['x'], + ], + ); + }); + + test('a stale failure does not surface as an error', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final staleLoadMore = controller.loadMore(); + final refreshed = controller.refresh(); + + fetcher.complete(2, page(['x'], cursor: 'cursor-new')); + await refreshed; + fetcher.fail(1, Exception('stale request failed')); + await staleLoadMore; + + expect(controller.error, isNull); + expect(controller.loadMoreError, isNull); + expect(controller.isLoadingMore, isFalse); + }); + }); + + group('errors', () { + test('a first-page failure sets error and clears isLoading', () async { + final controller = build(); + addTearDown(controller.dispose); + + final future = controller.refresh(); + fetcher.fail(0, Exception('boom')); + await future; + + expect(controller.error, isNotNull); + expect(controller.error, contains('boom')); + expect(controller.isLoading, isFalse); + expect(controller.loadMoreError, isNull); + }); + + test('a loadMore failure sets loadMoreError only', () async { + // Profile's shared-error-field bug: a pagination failure must never + // populate the field that drives the full-screen error state. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final future = controller.loadMore(); + fetcher.fail(1, Exception('page 2 exploded')); + await future; + + expect(controller.loadMoreError, isNotNull); + expect(controller.error, isNull); + expect(controller.isLoadingMore, isFalse); + // Already-loaded items and the cursor survive so a retry can resume. + expect(controller.items, ['a', 'b']); + expect(controller.cursor, 'cursor-1'); + expect(controller.hasMore, isTrue); + }); + + test('refresh clears both error and loadMoreError', () async { + // Community feed: a stale load-more error used to survive a refresh + // and a sort change. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('page 2 exploded')); + await failed; + expect(controller.loadMoreError, isNotNull); + + final refreshed = controller.refresh(); + // Cleared optimistically, before the new page even lands. + expect(controller.loadMoreError, isNull); + expect(controller.error, isNull); + + fetcher.complete(2, page(['a'], cursor: 'cursor-1')); + await refreshed; + + expect(controller.loadMoreError, isNull); + expect(controller.error, isNull); + }); + + test('clearLoadMoreError clears only the load-more error', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('page 2 exploded')); + await failed; + + controller.clearLoadMoreError(); + + expect(controller.loadMoreError, isNull); + expect(controller.items, ['a', 'b']); + }); + + test('retry re-runs the first page and clears the error', () async { + final controller = build(); + addTearDown(controller.dispose); + + final failed = controller.refresh(); + fetcher.fail(0, Exception('boom')); + await failed; + expect(controller.error, isNotNull); + + final retried = controller.retry(); + fetcher.complete(1, page(['a'], cursor: 'c1')); + await retried; + + expect(controller.error, isNull); + expect(controller.items, ['a']); + }); + + test('errorMapper maps both first-page and load-more failures', () async { + final controller = build(errorMapper: (error) => 'mapped'); + addTearDown(controller.dispose); + + final failedFirst = controller.refresh(); + fetcher.fail(0, Exception('raw first page')); + await failedFirst; + expect(controller.error, 'mapped'); + + final ok = controller.refresh(); + fetcher.complete(1, page(['a'], cursor: 'c1')); + await ok; + + final failedMore = controller.loadMore(); + fetcher.fail(2, Exception('raw page 2')); + await failedMore; + expect(controller.loadMoreError, 'mapped'); + }); + + test('refresh and loadMore never rethrow', () async { + final controller = build(); + addTearDown(controller.dispose); + + final first = controller.refresh(); + fetcher.fail(0, Exception('boom')); + await expectLater(first, completes); + }); + }); + + group('onPageLoaded', () { + test('receives only the new items of each page', () async { + final hydrated = >[]; + final controller = build( + onPageLoaded: (newItems) async => hydrated.add(newItems), + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(['c'], cursor: 'cursor-2')); + await more; + + expect( + hydrated, + >[ + ['a', 'b'], + ['c'], + ], + ); + }); + + test('runs after the new items are visible on the controller', () async { + // Hydration hooks (vote/subscription seeding) read the controller's + // state, so the append must already be committed when they run. + late CursorPaginationController controller; + var itemsDuringHook = []; + + controller = CursorPaginationController( + fetchPage: fetcher.call, + onPageLoaded: (newItems) async { + itemsDuringHook = List.of(controller.items); + }, + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(['c'], cursor: 'cursor-2')); + await more; + + expect(itemsDuringHook, ['a', 'b', 'c']); + }); + + test('a throwing hook does not corrupt the loaded page', () async { + final controller = build( + onPageLoaded: (newItems) async => throw StateError('hydration'), + ); + addTearDown(controller.dispose); + + final future = controller.refresh(); + fetcher.complete(0, page(['a'], cursor: 'c1')); + await future; + + expect(controller.items, ['a']); + expect(controller.isLoading, isFalse); + }); + }); + + group('refresh result', () { + test('reports whether this call put its own page on screen', () async { + // Awaiters (the profile provider stamps a "last refreshed" timestamp) + // need to know a fetch of *theirs* actually completed. + final controller = build(); + addTearDown(controller.dispose); + + final ok = controller.refresh(); + fetcher.complete(0, page(['a'], cursor: 'c1')); + expect(await ok, isTrue); + + final failed = controller.refresh(); + fetcher.fail(1, Exception('boom')); + expect(await failed, isFalse); + }); + + test('a refresh orphaned by reset reports false', () async { + final controller = build(); + addTearDown(controller.dispose); + + final orphaned = controller.refresh(); + controller.reset(); + fetcher.complete(0, page(['a'], cursor: 'c1')); + + expect(await orphaned, isFalse); + expect(controller.items, isEmpty); + }); + }); + + group('failed refresh from a populated state', () { + test('keeps the items and cursor already on screen', () async { + // Pull-to-refresh with content on screen: a failure must not blank + // the list, and the surviving cursor keeps pagination usable. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.refresh(); + fetcher.fail(1, NetworkFailure()); + expect(await failed, isFalse); + + expect(controller.items, ['a', 'b']); + expect(controller.cursor, 'cursor-1'); + expect(controller.hasMore, isTrue); + expect(controller.isLoading, isFalse); + // The failure is reported on the first-page channel even though the + // list is non-empty — screens surface it in the footer instead of a + // full-screen error (PaginatedSliverList.refreshError). + expect(controller.error, isNotNull); + expect(controller.loadMoreError, isNull); + }); + + test('a later successful refresh clears the error', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.refresh(); + fetcher.fail(1, NetworkFailure()); + await failed; + + final ok = controller.refresh(); + fetcher.complete(2, page(['z'], cursor: 'cursor-9')); + expect(await ok, isTrue); + + expect(controller.error, isNull); + expect(controller.items, ['z']); + }); + }); + + group('load-more error gating', () { + test('loadMore is a no-op while a footer error is showing', () async { + // The scroll trigger keeps firing while the user sits at the bottom; + // without this gate a failed page retries ~10x/second. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('offline')); + await failed; + + await controller.loadMore(); + await controller.loadMore(); + + expect(fetcher.requestCount, 2); + expect(controller.loadMoreError, isNotNull); + }); + + test('retryLoadMore clears the error and fetches the page', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('offline')); + await failed; + + final retried = controller.retryLoadMore(); + expect(controller.loadMoreError, isNull); + expect(fetcher.requestCount, 3); + fetcher.complete(2, page(['c'], cursor: 'cursor-2')); + await retried; + + expect(controller.items, ['a', 'b', 'c']); + expect(controller.loadMoreError, isNull); + }); + + test('refresh re-enables loadMore after a footer error', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('offline')); + await failed; + + final refreshed = controller.refresh(); + fetcher.complete(2, page(['a'], cursor: 'cursor-1')); + await refreshed; + + final more = controller.loadMore(); + fetcher.complete(3, page(['c'], cursor: 'cursor-2')); + await more; + + expect(controller.items, ['a', 'c']); + }); + }); + + group('empty pages', () { + test('an empty load-more page ends the feed even with a cursor', + () async { + // Otherwise the cursor is polled forever by the scroll trigger. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(const [], cursor: 'cursor-2')); + await more; + + expect(controller.items, ['a', 'b']); + expect(controller.hasMore, isFalse); + expect(controller.cursor, isNull); + + await controller.loadMore(); + expect(fetcher.requestCount, 2); + }); + }); + + group('duplicate ids', () { + test('an item already on screen is not appended twice', () async { + // Cursor drift on the server hands back an overlapping page; the + // sliver keys rows by id, so a duplicate is a hard assertion crash. + final controller = build(idOf: (item) => item); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(['b', 'c'], cursor: 'cursor-2')); + await more; + + expect(controller.items, ['a', 'b', 'c']); + expect(controller.cursor, 'cursor-2'); + }); + + test('duplicates inside a single page are dropped', () async { + final controller = build(idOf: (item) => item); + addTearDown(controller.dispose); + + await loadFirstPage(controller, items: ['a', 'a', 'b']); + + expect(controller.items, ['a', 'b']); + }); + + test('only the genuinely new items are hydrated', () async { + final hydrated = >[]; + final controller = build( + idOf: (item) => item, + onPageLoaded: (newItems) async => hydrated.add(newItems), + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(['b', 'c'], cursor: 'cursor-2')); + await more; + + expect( + hydrated, + >[ + ['a', 'b'], + ['c'], + ], + ); + }); + + test('without idOf the page is appended verbatim', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final more = controller.loadMore(); + fetcher.complete(1, page(['b'], cursor: 'cursor-2')); + await more; + + expect(controller.items, ['a', 'b', 'b']); + }); + }); + + group('removeWhere', () { + test('drops the matching items and notifies', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller, items: ['a', 'b', 'c']); + + var notifications = 0; + controller + ..addListener(() => notifications++) + ..removeWhere((item) => item == 'b'); + + expect(controller.items, ['a', 'c']); + expect(notifications, 1); + }); + + test('a no-op removal does not notify', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + var notifications = 0; + controller + ..addListener(() => notifications++) + ..removeWhere((item) => item == 'nope'); + + expect(controller.items, ['a', 'b']); + expect(notifications, 0); + }); + + test('leaves the cursor and hasMore alone', () async { + // Server-side page boundaries do not move because the client stopped + // showing a row. + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + controller.removeWhere((item) => item == 'a'); + + expect(controller.cursor, 'cursor-1'); + expect(controller.hasMore, isTrue); + }); + }); + + group('error reporting', () { + test('a throwing errorMapper falls back to the error text', () async { + // A mapper that throws used to wedge the controller: the message was + // computed BEFORE the loading flags were cleared, and the throw + // escaped refresh() (which promises never to rethrow). + final controller = build( + errorMapper: (error) => throw StateError('mapper exploded'), + ); + addTearDown(controller.dispose); + + final failed = controller.refresh(); + fetcher.fail(0, Exception('boom')); + await expectLater(failed, completes); + + expect(controller.isLoading, isFalse); + expect(controller.error, contains('boom')); + }); + + test('a throwing errorMapper does not wedge load-more either', () async { + final controller = build( + errorMapper: (error) => throw StateError('mapper exploded'), + ); + addTearDown(controller.dispose); + + final first = controller.refresh(); + fetcher.complete(0, page(['a'], cursor: 'c1')); + await first; + + final failed = controller.loadMore(); + fetcher.fail(1, Exception('page 2 exploded')); + await expectLater(failed, completes); + + expect(controller.isLoadingMore, isFalse); + expect(controller.loadMoreError, contains('page 2 exploded')); + }); + + test('onUnexpectedError sees first-page and load-more failures', + () async { + final reported = []; + final controller = build( + onUnexpectedError: (error, stack) => reported.add(error), + ); + addTearDown(controller.dispose); + + final failedFirst = controller.refresh(); + fetcher.fail(0, StateError('first page')); + await failedFirst; + + final ok = controller.refresh(); + fetcher.complete(1, page(['a'], cursor: 'c1')); + await ok; + + final failedMore = controller.loadMore(); + fetcher.fail(2, StateError('page 2')); + await failedMore; + + expect(reported, hasLength(2)); + expect(reported.first, isA()); + }); + + test('onUnexpectedError sees hydration failures', () async { + // Vote hydration failing silently is how wrong vote state ships. + final reported = []; + final controller = build( + onPageLoaded: (newItems) async => throw StateError('vote hydration'), + onUnexpectedError: (error, stack) => reported.add(error), + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + expect(reported, hasLength(1)); + expect(reported.single, isA()); + // The page itself still loaded. + expect(controller.items, ['a', 'b']); + }); + + test('onUnexpectedError sees failures of superseded requests', () async { + final reported = []; + final controller = build( + onUnexpectedError: (error, stack) => reported.add(error), + ); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + + final staleLoadMore = controller.loadMore(); + final refreshed = controller.refresh(); + fetcher.complete(2, page(['x'], cursor: 'cursor-new')); + await refreshed; + fetcher.fail(1, StateError('stale boom')); + await staleLoadMore; + + expect(reported, hasLength(1)); + // ...without polluting the visible state. + expect(controller.error, isNull); + expect(controller.loadMoreError, isNull); + }); + + test('a throwing onUnexpectedError does not wedge the controller', + () async { + final controller = build( + onUnexpectedError: (error, stack) => throw StateError('sentry down'), + ); + addTearDown(controller.dispose); + + final failed = controller.refresh(); + fetcher.fail(0, Exception('boom')); + await expectLater(failed, completes); + + expect(controller.isLoading, isFalse); + expect(controller.error, isNotNull); + }); + }); + + group('reset', () { + test('clears items, cursor, flags and both errors', () async { + final controller = build(); + addTearDown(controller.dispose); + + await loadFirstPage(controller); + final failed = controller.loadMore(); + fetcher.fail(1, Exception('page 2 exploded')); + await failed; + + controller.reset(); + + expect(controller.items, isEmpty); + expect(controller.cursor, isNull); + expect(controller.hasMore, isFalse); + expect(controller.isLoading, isFalse); + expect(controller.isLoadingMore, isFalse); + expect(controller.error, isNull); + expect(controller.loadMoreError, isNull); + }); + }); + + group('dispose', () { + test('a page landing after dispose does not notify or throw', () async { + final controller = build(); + + final future = controller.refresh(); + controller.dispose(); + fetcher.complete(0, page(['a'], cursor: 'c1')); + + await expectLater(future, completes); + }); + }); +} diff --git a/test/utils/date_time_utils_test.dart b/test/utils/date_time_utils_test.dart index 98e48aa..5053f48 100644 --- a/test/utils/date_time_utils_test.dart +++ b/test/utils/date_time_utils_test.dart @@ -87,35 +87,8 @@ void main() { }); }); - group('DateTimeUtils.formatCount', () { - test('formats numbers less than 1000 as-is', () { - expect(DateTimeUtils.formatCount(0), '0'); - expect(DateTimeUtils.formatCount(1), '1'); - expect(DateTimeUtils.formatCount(42), '42'); - expect(DateTimeUtils.formatCount(999), '999'); - }); - - test('formats 1000 as 1.0k', () { - expect(DateTimeUtils.formatCount(1000), '1.0k'); - }); - - test('formats thousands with one decimal place', () { - expect(DateTimeUtils.formatCount(1500), '1.5k'); - expect(DateTimeUtils.formatCount(2300), '2.3k'); - expect(DateTimeUtils.formatCount(5678), '5.7k'); - }); - - test('formats large numbers correctly', () { - expect(DateTimeUtils.formatCount(10000), '10.0k'); - expect(DateTimeUtils.formatCount(42500), '42.5k'); - expect(DateTimeUtils.formatCount(999999), '1000.0k'); - }); - - test('rounds to one decimal place', () { - expect(DateTimeUtils.formatCount(1234), '1.2k'); - expect(DateTimeUtils.formatCount(1567), '1.6k'); - }); - }); + // Count formatting moved to DisplayUtils.formatCount — see + // test/utils/display_utils_test.dart for its spec. group('DateTimeUtils.formatFullDateTime', () { test('formats midnight (12:00 AM) correctly', () { diff --git a/test/utils/display_utils_test.dart b/test/utils/display_utils_test.dart new file mode 100644 index 0000000..190ca98 --- /dev/null +++ b/test/utils/display_utils_test.dart @@ -0,0 +1,64 @@ +import 'package:coves_flutter/utils/display_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Spec for the single canonical count formatter. +/// +/// `DisplayUtils.formatCount` is the one implementation the whole UI is +/// expected to render counts through (vote scores, comment counts, member +/// counts). Its contract is uppercase K/M with one decimal place. +void main() { + group('DisplayUtils.formatCount', () { + test('renders values below 1000 unchanged', () { + expect(DisplayUtils.formatCount(0), '0'); + expect(DisplayUtils.formatCount(1), '1'); + expect(DisplayUtils.formatCount(42), '42'); + expect(DisplayUtils.formatCount(999), '999'); + }); + + test('renders thousands with an uppercase K', () { + expect(DisplayUtils.formatCount(1000), '1.0K'); + expect(DisplayUtils.formatCount(1234), '1.2K'); + expect(DisplayUtils.formatCount(1500), '1.5K'); + expect(DisplayUtils.formatCount(5234), '5.2K'); + expect(DisplayUtils.formatCount(10000), '10.0K'); + expect(DisplayUtils.formatCount(42500), '42.5K'); + }); + + test('rounds to a single decimal place', () { + expect(DisplayUtils.formatCount(1567), '1.6K'); + expect(DisplayUtils.formatCount(5678), '5.7K'); + expect(DisplayUtils.formatCount(1470000), '1.5M'); + }); + + test('rounds 999999 up into the K tier rather than the M tier', () { + // Documented rounding artifact: 999.999K renders as "1000.0K" because + // the tier is chosen before the value is rounded. Kept as-is so the + // formatter never disagrees with itself across call sites. + expect(DisplayUtils.formatCount(999999), '1000.0K'); + }); + + test('renders millions with an uppercase M', () { + expect(DisplayUtils.formatCount(1000000), '1.0M'); + expect(DisplayUtils.formatCount(1500000), '1.5M'); + expect(DisplayUtils.formatCount(12300000), '12.3M'); + }); + + test('boundaries select the expected tier', () { + expect(DisplayUtils.formatCount(999), '999'); + expect(DisplayUtils.formatCount(1000), '1.0K'); + expect(DisplayUtils.formatCount(999999), '1000.0K'); + expect(DisplayUtils.formatCount(1000000), '1.0M'); + }); + + test('negative counts are rendered raw (characterization)', () { + // Neither tier threshold matches a negative value, so negatives fall + // through to `toString()`. Vote scores can legitimately go negative, + // so this is the shipped behavior, not an accident to preserve + // silently: -1500 shows as "-1500", never "-1.5K". + expect(DisplayUtils.formatCount(-1), '-1'); + expect(DisplayUtils.formatCount(-999), '-999'); + expect(DisplayUtils.formatCount(-1500), '-1500'); + expect(DisplayUtils.formatCount(-1500000), '-1500000'); + }); + }); +} diff --git a/test/utils/pagination_scroll_listener_test.dart b/test/utils/pagination_scroll_listener_test.dart new file mode 100644 index 0000000..ee90343 --- /dev/null +++ b/test/utils/pagination_scroll_listener_test.dart @@ -0,0 +1,331 @@ +// RED-phase spec for the shared "near the bottom" pagination trigger. +// +// Compile-red until lib/utils/pagination_scroll_listener.dart exists. Kept +// self-contained so the rest of the suite still compiles. +// +// Mirrors feed_screen.dart:152-170 (the only current implementation that +// throttles) and replaces: +// - community_feed_screen.dart:96-101 (-200px, no throttle) +// - communities_see_all_screen.dart:90-97 (percentage trigger) +// - profile_screen.dart:509/590 (build-phase triggers from itemBuilder) +// +// The listener attaches to an externally-owned ScrollController and must +// never dispose it. + +import 'package:coves_flutter/utils/pagination_scroll_listener.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A hand-driven clock so throttle windows are deterministic — the widget +/// tester's fake async does not move DateTime.now(). +class _FakeClock { + DateTime value = DateTime.utc(2026); + + DateTime now() => value; + + void advance(Duration by) => value = value.add(by); +} + +void main() { + late ScrollController controller; + late _FakeClock clock; + late int calls; + + setUp(() { + controller = ScrollController(); + clock = _FakeClock(); + calls = 0; + }); + + PaginationScrollListener buildListener({ + double threshold = 200, + Duration throttle = const Duration(milliseconds: 100), + }) { + return PaginationScrollListener( + controller: controller, + onLoadMore: () => calls++, + threshold: threshold, + throttle: throttle, + clock: clock.now, + ); + } + + Future pumpList(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + controller: controller, + itemCount: 50, + itemBuilder: (context, index) => SizedBox( + height: 100, + child: Text('item $index'), + ), + ), + ), + ), + ); + } + + double bottomMinus(double offset) { + return controller.position.maxScrollExtent - offset; + } + + testWidgets('does not fire before attach', (tester) async { + await pumpList(tester); + buildListener(); + + controller.jumpTo(bottomMinus(10)); + await tester.pump(); + + expect(calls, 0); + }); + + testWidgets('fires when scrolled within 200px of the bottom', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('does not fire further than 200px from the bottom', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(201)); + await tester.pump(); + + expect(calls, 0); + }); + + testWidgets('fires exactly at the threshold boundary', (tester) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(200)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('honours a custom threshold', (tester) async { + await pumpList(tester); + final listener = buildListener(threshold: 600)..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(500)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('throttles repeat triggers inside the 100ms window', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + clock.advance(const Duration(milliseconds: 40)); + controller.jumpTo(bottomMinus(120)); + await tester.pump(); + clock.advance(const Duration(milliseconds: 40)); + controller.jumpTo(bottomMinus(90)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('fires again once the throttle window has passed', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + clock.advance(const Duration(milliseconds: 150)); + controller.jumpTo(bottomMinus(120)); + await tester.pump(); + + expect(calls, 2); + }); + + testWidgets('stops firing after detach', (tester) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + expect(calls, 1); + + listener.detach(); + clock.advance(const Duration(milliseconds: 500)); + controller.jumpTo(bottomMinus(50)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('re-attaching resumes firing', (tester) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + listener + ..detach() + ..attach(); + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('attach is idempotent (no double fire)', (tester) async { + await pumpList(tester); + final listener = buildListener() + ..attach() + ..attach(); + addTearDown(listener.dispose); + + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + + expect(calls, 1); + }); + + group('checkNow', () { + // Regression: the profile screen used to trigger pagination from its + // item builder, which auto-loaded when a short first page did not fill + // the viewport. A scroll listener alone never fires in that case, so + // adopting screens ask explicitly once a page has landed. + Future pumpShortList(WidgetTester tester, {int items = 3}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + controller: controller, + itemCount: items, + itemBuilder: (context, index) => SizedBox( + height: 100, + child: Text('item $index'), + ), + ), + ), + ), + ); + } + + testWidgets('fires when the content does not fill the viewport', ( + tester, + ) async { + await pumpShortList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + expect(controller.position.maxScrollExtent, 0); + + listener.checkNow(); + + expect(calls, 1); + }); + + testWidgets('does not fire when the content is far from the bottom', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + listener.checkNow(); + + expect(calls, 0); + }); + + testWidgets('shares the throttle window with scroll triggers', ( + tester, + ) async { + await pumpShortList(tester); + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + listener + ..checkNow() + ..checkNow(); + + expect(calls, 1); + + clock.advance(const Duration(milliseconds: 150)); + listener.checkNow(); + + expect(calls, 2); + }); + + testWidgets('does nothing before attach or after detach', (tester) async { + await pumpShortList(tester); + final listener = buildListener(); + addTearDown(listener.dispose); + + listener.checkNow(); + expect(calls, 0); + + listener + ..attach() + ..detach() + ..checkNow(); + + expect(calls, 0); + }); + + testWidgets('does nothing without a scroll client', (tester) async { + final listener = buildListener()..attach(); + addTearDown(listener.dispose); + + expect(controller.hasClients, isFalse); + expect(listener.checkNow, returnsNormally); + expect(calls, 0); + }); + }); + + testWidgets('dispose does not dispose the borrowed controller', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + + listener.dispose(); + + // Still usable by its owner. + controller.jumpTo(bottomMinus(150)); + await tester.pump(); + expect(calls, 0); + expect(controller.hasClients, isTrue); + }); + + testWidgets('dispose is safe after the controller was disposed', ( + tester, + ) async { + await pumpList(tester); + final listener = buildListener()..attach(); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + + expect(listener.dispose, returnsNormally); + expect(listener.dispose, returnsNormally); + }); +} diff --git a/test/utils/url_display_test.dart b/test/utils/url_display_test.dart new file mode 100644 index 0000000..87df568 --- /dev/null +++ b/test/utils/url_display_test.dart @@ -0,0 +1,100 @@ +// Spec for the shared link-display helpers. +// +// Three surfaces derive a display domain and a favicon domain from a URL +// today — external_link_bar.dart, source_link_bar.dart and +// detailed_post_view.dart (`_formatUrlForDisplay`) — each with its own copy +// of the parse-and-fall-back dance. One home, two functions. +// +// Target API — lib/utils/url_display.dart: +// +// /// The host of [url], or null when it has none (unparseable, relative, +// /// or authority-less). Never throws. +// String? domainOf(String url); +// +// /// [url] with the scheme, port, query and fragment stripped: +// /// `example.com` or `example.com/a/b`. Returns [url] unchanged when it +// /// has no host. Never throws. +// String hostAndPath(String url); +// +// Callers keep their own precedence rules (the link bars prefer the embed's +// declared `domain` field and fall back to `domainOf(uri) ?? uri`); these +// helpers only do the parsing. +// +// COMPILE-RED until lib/utils/url_display.dart exists. + +import 'package:coves_flutter/utils/url_display.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('domainOf', () { + test('returns the host of an absolute url', () { + expect(domainOf('https://example.com'), 'example.com'); + expect(domainOf('https://example.com/a/b?c=d#e'), 'example.com'); + expect(domainOf('http://news.example.co.uk/story'), 'news.example.co.uk'); + }); + + test('lowercases the host', () { + expect(domainOf('https://EXAMPLE.com/Path'), 'example.com'); + }); + + test('drops the port and any credentials', () { + expect(domainOf('https://example.com:8443/x'), 'example.com'); + }); + + test('returns null when there is no host', () { + expect(domainOf(''), isNull); + expect(domainOf('just-text'), isNull); + expect(domainOf('/relative/path'), isNull); + expect(domainOf('https://'), isNull); + }); + + test('never throws on a malformed url', () { + // Callers feed this untrusted record data, so it has to be total. + for (final input in [ + 'http://[', + '://nope', + 'https://exa mple.com', + '%%%', + ]) { + expect(() => domainOf(input), returnsNormally, reason: input); + } + }); + }); + + group('hostAndPath', () { + test('returns the bare host when there is no path', () { + expect(hostAndPath('https://example.com'), 'example.com'); + expect(hostAndPath('https://example.com/'), 'example.com'); + }); + + test('keeps the path', () { + expect(hostAndPath('https://example.com/a/b'), 'example.com/a/b'); + expect( + hostAndPath('https://example.com/2026/08/a-story'), + 'example.com/2026/08/a-story', + ); + }); + + test('drops the scheme, query and fragment', () { + expect(hostAndPath('https://example.com/a?utm=x'), 'example.com/a'); + expect(hostAndPath('https://example.com/a#top'), 'example.com/a'); + expect(hostAndPath('http://example.com/a'), 'example.com/a'); + }); + + test('returns the input unchanged when there is no host', () { + expect(hostAndPath('just-text'), 'just-text'); + expect(hostAndPath(''), ''); + }); + + test('never throws on a malformed url', () { + for (final input in [ + 'http://[', + '://nope', + 'https://exa mple.com', + '%%%', + ]) { + expect(() => hostAndPath(input), returnsNormally, reason: input); + } + }); + }); +} diff --git a/test/utils/url_launcher_test.dart b/test/utils/url_launcher_test.dart index 4afa3b2..74568be 100644 --- a/test/utils/url_launcher_test.dart +++ b/test/utils/url_launcher_test.dart @@ -65,6 +65,36 @@ void main() { // URL gets normalized to lowercase by url_launcher expect(mockPlatform.launchedUrls, contains('https://example.com')); }); + + // An allowed scheme with no authority is not a web link. The model + // layer (_isRenderableMediaUrl) and FacetDetector already reject these; + // the launcher is the outbound edge and must agree, or a hostile record + // can hand the platform a scheme-only uri that resolves per-OS. + test('blocks http: with an opaque path and no host', () async { + final result = await UrlLauncher.launchExternalUrl('http:foo'); + expect(result, false); + expect(mockPlatform.launchedUrls, isEmpty); + }); + + test('blocks https:/// with an empty authority', () async { + final result = await UrlLauncher.launchExternalUrl('https:///path'); + expect(result, false); + expect(mockPlatform.launchedUrls, isEmpty); + }); + + test('blocks a bare http:// with nothing after it', () async { + final result = await UrlLauncher.launchExternalUrl('http://'); + expect(result, false); + expect(mockPlatform.launchedUrls, isEmpty); + }); + + test('blocks a scheme that merely starts with http', () async { + final result = await UrlLauncher.launchExternalUrl( + 'httpx://evil.com', + ); + expect(result, false); + expect(mockPlatform.launchedUrls, isEmpty); + }); }); group('Invalid URL Handling', () { diff --git a/test/utils/url_policy_test.dart b/test/utils/url_policy_test.dart new file mode 100644 index 0000000..f79e3d8 --- /dev/null +++ b/test/utils/url_policy_test.dart @@ -0,0 +1,133 @@ +// Spec for the single canonical http/https allowlist. +// +// RED: this file does not compile until lib/utils/url_policy.dart exists. +// The predicate is deliberately the STRICTEST of the five copies it replaces: +// scheme allowlist AND non-empty host. +import 'package:coves_flutter/utils/url_policy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('kAllowedWebSchemes', () { + test('is exactly http and https', () { + expect(kAllowedWebSchemes, {'http', 'https'}); + }); + }); + + group('isAllowedWebUrl accepts', () { + test('https url with host', () { + expect(isAllowedWebUrl('https://example.com'), isTrue); + }); + + test('http url with host', () { + expect(isAllowedWebUrl('http://example.com'), isTrue); + }); + + test('uppercase scheme (case-insensitive)', () { + expect(isAllowedWebUrl('HTTPS://example.com'), isTrue); + expect(isAllowedWebUrl('HtTp://example.com'), isTrue); + }); + + test('url with port, path, query and fragment', () { + expect( + isAllowedWebUrl('https://example.com:8443/a/b?q=1&r=2#frag'), + isTrue, + ); + }); + + test('url with userinfo and subdomain', () { + expect(isAllowedWebUrl('https://user@cdn.example.co.uk/x.jpg'), isTrue); + }); + + test('bare host with trailing slash', () { + expect(isAllowedWebUrl('http://example.com/'), isTrue); + }); + + test('ip literal host', () { + expect(isAllowedWebUrl('http://127.0.0.1:8080/health'), isTrue); + }); + }); + + group('isAllowedWebUrl rejects non-http(s) schemes', () { + test('javascript:', () { + expect(isAllowedWebUrl('javascript:alert("xss")'), isFalse); + }); + + test('data:', () { + expect(isAllowedWebUrl('data:text/html,

XSS

'), isFalse); + }); + + test('file:', () { + expect(isAllowedWebUrl('file:///etc/passwd'), isFalse); + }); + + test('ftp:', () { + expect(isAllowedWebUrl('ftp://example.com/pub'), isFalse); + }); + + test('content:', () { + expect(isAllowedWebUrl('content://media/external/images/1'), isFalse); + }); + + test('scheme that merely starts with http', () { + expect(isAllowedWebUrl('httpx://evil.com'), isFalse); + expect(isAllowedWebUrl('httpsevil://evil.com'), isFalse); + expect(isAllowedWebUrl('HTTPX://evil.com'), isFalse); + }); + }); + + group('isAllowedWebUrl rejects allowed schemes with no host', () { + test('http:foo (opaque path, no authority)', () { + expect(isAllowedWebUrl('http:foo'), isFalse); + }); + + test('https:///path (empty authority)', () { + expect(isAllowedWebUrl('https:///path'), isFalse); + }); + + test('http:// (nothing at all)', () { + expect(isAllowedWebUrl('http://'), isFalse); + }); + + test('https://:8080/x (port but no host)', () { + expect(isAllowedWebUrl('https://:8080/x'), isFalse); + }); + }); + + group('isAllowedWebUrl rejects degenerate input', () { + test('null', () { + expect(isAllowedWebUrl(null), isFalse); + }); + + test('empty string', () { + expect(isAllowedWebUrl(''), isFalse); + }); + + test('whitespace only', () { + expect(isAllowedWebUrl(' '), isFalse); + }); + + test('no scheme at all', () { + expect(isAllowedWebUrl('example.com'), isFalse); + expect(isAllowedWebUrl('//example.com/path'), isFalse); + expect(isAllowedWebUrl('/just/a/path'), isFalse); + }); + + test('unparseable garbage', () { + expect(isAllowedWebUrl('not a url'), isFalse); + expect(isAllowedWebUrl('ht tp://bad url'), isFalse); + }); + + test('never throws for hostile input', () { + const hostile = [ + 'http:foo', + 'https:///path', + '::::', + '%%%', + 'https://[not-an-ip]/x', + ]; + for (final url in hostile) { + expect(() => isAllowedWebUrl(url), returnsNormally, reason: url); + } + }); + }); +} diff --git a/test/widgets/comment_card_avatar_test.dart b/test/widgets/comment_card_avatar_test.dart new file mode 100644 index 0000000..9c04958 --- /dev/null +++ b/test/widgets/comment_card_avatar_test.dart @@ -0,0 +1,162 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/constants/app_colors.dart'; +import 'package:coves_flutter/models/comment.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/providers/auth_provider.dart'; +import 'package:coves_flutter/providers/block_provider.dart'; +import 'package:coves_flutter/providers/vote_provider.dart'; +import 'package:coves_flutter/utils/display_utils.dart'; +import 'package:coves_flutter/widgets/comment_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/test_mocks.dart'; + +/// RED: CommentCard's hand-rolled author avatar. +/// +/// The avatar image is built at 14x14 while its own fallback is 24x24, so the +/// header row reflows the moment an avatar fails to load. The fallback also +/// takes its initial from `handle` even when a displayName exists, and paints +/// AppColors.primary instead of the shared hash color. +void main() { + // Hashes onto a non-coral palette slot, so "shared hash color" and the + // legacy AppColors.primary are distinguishable. + const authorHandle = 'commenter.test'; + + + late MockAuthProvider mockAuthProvider; + late MockVoteProvider mockVoteProvider; + late MockCovesApiService mockApiService; + late BlockProvider blockProvider; + + setUp(() { + mockAuthProvider = MockAuthProvider(); + mockVoteProvider = MockVoteProvider(); + mockApiService = MockCovesApiService(); + blockProvider = BlockProvider( + apiService: mockApiService, + authProvider: mockAuthProvider, + ); + + when(mockAuthProvider.isAuthenticated).thenReturn(false); + when(mockVoteProvider.isLiked(any)).thenReturn(false); + when(mockVoteProvider.getAdjustedScore(any, any)).thenAnswer( + (invocation) => invocation.positionalArguments[1] as int, + ); + }); + + CommentView createComment({ + required String handle, + String? displayName, + String? avatar, + }) { + return CommentView( + uri: 'at://did:plc:test/comment/1', + cid: 'cid-1', + record: const CommentRecord(content: 'Test comment'), + createdAt: DateTime(2025), + indexedAt: DateTime(2025), + author: AuthorView( + did: 'did:plc:author', + handle: handle, + displayName: displayName, + avatar: avatar, + ), + post: CommentRef(uri: 'at://did:plc:test/post/123', cid: 'post-cid'), + stats: const CommentStats(upvotes: 5, downvotes: 1, score: 4), + ); + } + + Widget createTestWidget(CommentView comment) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: mockAuthProvider), + ChangeNotifierProvider.value(value: mockVoteProvider), + ChangeNotifierProvider.value(value: blockProvider), + ], + child: MaterialApp( + home: Scaffold(body: CommentCard(comment: comment)), + ), + ); + } + + Size boxSizeAround(WidgetTester tester, Finder inner) => tester.getSize( + find.ancestor(of: inner, matching: find.byType(DecoratedBox)).first, + ); + + Color paintedColorBehind(WidgetTester tester, Finder inner) { + final decorated = tester.widget( + find.ancestor(of: inner, matching: find.byType(DecoratedBox)).first, + ); + return (decorated.decoration as BoxDecoration).color!; + } + + group('CommentCard author avatar', () { + testWidgets('fallback occupies a 24x24 box', (tester) async { + // Characterization: this is the size the image path must match. + await tester.pumpWidget( + createTestWidget(createComment(handle: authorHandle)), + ); + + expect(boxSizeAround(tester, find.text('C')), const Size(24, 24)); + }); + + testWidgets('image is requested at the same 24x24 box as the fallback', ( + tester, + ) async { + // Asserting on the requested width/height rather than the rendered size + // is deliberate: under flutter_test the network image never resolves, + // so the *rendered* widget is the placeholder (the 24x24 fallback) in + // both cases and the mismatch would be invisible. The declared size is + // what reflows the row on a real device once the image arrives. + await tester.pumpWidget( + createTestWidget( + createComment( + handle: authorHandle, + avatar: 'https://example.com/avatar.jpg', + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.width, 24, reason: 'avatar image must not shrink to 14'); + expect(image.height, 24, reason: 'avatar image must not shrink to 14'); + }); + + testWidgets('fallback initial prefers displayName over handle', ( + tester, + ) async { + await tester.pumpWidget( + createTestWidget( + createComment(handle: authorHandle, displayName: 'Ada Lovelace'), + ), + ); + + expect(find.text('A'), findsOneWidget); + expect(find.text('C'), findsNothing); + }); + + testWidgets('fallback uses the shared hash color, not AppColors.primary', ( + tester, + ) async { + expect( + DisplayUtils.getFallbackColor(authorHandle).toARGB32(), + isNot(AppColors.primary.toARGB32()), + reason: 'fixture handle must hash away from the legacy color', + ); + + await tester.pumpWidget( + createTestWidget(createComment(handle: authorHandle)), + ); + + expect( + paintedColorBehind(tester, find.text('C')).toARGB32(), + DisplayUtils.getFallbackColor(authorHandle).toARGB32(), + ); + }); + }); +} diff --git a/test/widgets/comment_thread_test.dart b/test/widgets/comment_thread_test.dart index bd30b54..c1d208c 100644 --- a/test/widgets/comment_thread_test.dart +++ b/test/widgets/comment_thread_test.dart @@ -48,6 +48,7 @@ void main() { String content = 'Test comment', String handle = 'test.user', int replyCount = 0, + int score = 4, bool isDeleted = false, String? deletionReason, }) { @@ -66,7 +67,7 @@ void main() { stats: CommentStats( upvotes: 5, downvotes: 1, - score: 4, + score: score, replyCount: replyCount, ), ); @@ -77,6 +78,7 @@ void main() { required String uri, String content = 'Test comment', int replyCount = 0, + int score = 4, bool isDeleted = false, String? deletionReason, List? replies, @@ -88,6 +90,7 @@ void main() { uri: uri, content: content, replyCount: replyCount, + score: score, isDeleted: isDeleted, deletionReason: deletionReason, ), @@ -148,6 +151,30 @@ void main() { expect(find.text('Hello, world!'), findsOneWidget); }); + testWidgets('formats the vote score through the canonical formatter', ( + tester, + ) async { + // CommentCard must agree with every other count surface: uppercase + // K/M via DisplayUtils.formatCount. + final thread = createThread(uri: 'comment/1', score: 5234); + + await tester.pumpWidget(createTestWidget(thread)); + + expect(find.text('5.2K'), findsOneWidget); + expect(find.text('5.2k'), findsNothing); + }); + + testWidgets('formats a millions-scale vote score with an M suffix', ( + tester, + ) async { + final thread = createThread(uri: 'comment/1', score: 1500000); + + await tester.pumpWidget(createTestWidget(thread)); + + expect(find.text('1.5M'), findsOneWidget); + expect(find.text('1500.0k'), findsNothing); + }); + testWidgets('renders nested replies when depth < maxDepth', (tester) async { final thread = createThread( diff --git a/test/widgets/community_avatar_fallback_icon_test.dart b/test/widgets/community_avatar_fallback_icon_test.dart new file mode 100644 index 0000000..40fb8b6 --- /dev/null +++ b/test/widgets/community_avatar_fallback_icon_test.dart @@ -0,0 +1,74 @@ +import 'package:coves_flutter/widgets/community_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// RED: [CommunityAvatar] has no `fallbackIcon` parameter yet. +/// +/// The admin-panel community avatars (communities_admin_panel.dart at 40px, +/// 100px and 120px) fall back to an icon instead of an initial. Migrating +/// them onto the canonical widget needs `Widget? fallbackIcon`: when set the +/// icon is rendered in place of the letter, everything else (size, shape, +/// hash background) unchanged. +/// +/// This file is deliberately separate from community_avatar_test.dart: it +/// references an API that does not exist, so it fails to compile until the +/// Green phase adds the parameter. Keeping it isolated lets the +/// characterization suite next door still run. +void main() { + Widget wrap(Widget child) => + MaterialApp(home: Scaffold(body: Center(child: child))); + + group('CommunityAvatar fallbackIcon', () { + testWidgets('renders the icon instead of the name initial', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: 'gaming', + size: 40, + fallbackIcon: Icon(Icons.workspaces_outlined, size: 20), + ), + ), + ); + + expect(find.byIcon(Icons.workspaces_outlined), findsOneWidget); + expect(find.text('G'), findsNothing); + }); + + testWidgets('still fills exactly size x size', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: 'gaming', + size: 100, + fallbackIcon: Icon(Icons.workspaces_outlined, size: 40), + ), + ), + ); + + expect( + tester.getSize( + find.ancestor( + of: find.byIcon(Icons.workspaces_outlined), + matching: find.byType(DecoratedBox), + ).first, + ), + const Size(100, 100), + ); + }); + + testWidgets('the icon is used for the empty-name case too', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: '', + size: 40, + fallbackIcon: Icon(Icons.workspaces_outlined, size: 20), + ), + ), + ); + + expect(find.byIcon(Icons.workspaces_outlined), findsOneWidget); + expect(find.text('?'), findsNothing); + }); + }); +} diff --git a/test/widgets/community_avatar_test.dart b/test/widgets/community_avatar_test.dart new file mode 100644 index 0000000..f475c3f --- /dev/null +++ b/test/widgets/community_avatar_test.dart @@ -0,0 +1,204 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/utils/display_utils.dart'; +import 'package:coves_flutter/widgets/community_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Characterization tests for the canonical [CommunityAvatar]. +/// +/// Every hand-rolled community avatar in the app is being migrated onto this +/// widget, so these tests pin the contract the migrated call sites inherit: +/// the fallback glyph, the deterministic hash color, the shape variants and +/// the sizing of both the fallback and the network-image paths. +/// +/// The NEW `fallbackIcon` parameter lives in +/// test/widgets/community_avatar_fallback_icon_test.dart — it references an +/// API that does not exist yet, so it is kept in its own file to avoid +/// compile-breaking the characterization suite. +void main() { + Widget wrap(Widget child) => + MaterialApp(home: Scaffold(body: Center(child: child))); + + /// Reads the background color painted behind [textFinder]. + /// + /// Asserting on the nearest [DecoratedBox] rather than on a `Container` + /// keeps this independent of whether the fallback is built with a + /// Container, a DecoratedBox, or a ClipOval-wrapped box. + Color paintedColorBehind(WidgetTester tester, Finder textFinder) { + final decorated = tester.widget( + find.ancestor(of: textFinder, matching: find.byType(DecoratedBox)).first, + ); + return (decorated.decoration as BoxDecoration).color!; + } + + BoxDecoration decorationBehind(WidgetTester tester, Finder textFinder) { + final decorated = tester.widget( + find.ancestor(of: textFinder, matching: find.byType(DecoratedBox)).first, + ); + return decorated.decoration as BoxDecoration; + } + + group('CommunityAvatar fallback', () { + testWidgets('renders the first letter of the name, uppercased', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const CommunityAvatar(name: 'gaming', size: 40)), + ); + + expect(find.text('G'), findsOneWidget); + }); + + testWidgets('renders "?" when the name is empty', (tester) async { + await tester.pumpWidget(wrap(const CommunityAvatar(name: '', size: 40))); + + expect(find.text('?'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('uses the deterministic hash color from DisplayUtils', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const CommunityAvatar(name: 'gaming', size: 40)), + ); + + expect( + paintedColorBehind(tester, find.text('G')).toARGB32(), + DisplayUtils.getFallbackColor('gaming').toARGB32(), + ); + }); + + testWidgets('the same name always yields the same color', (tester) async { + await tester.pumpWidget( + wrap( + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + CommunityAvatar(name: 'gaming', size: 40), + CommunityAvatar(name: 'gaming', size: 24), + ], + ), + ), + ); + + final colors = tester + .widgetList(find.byType(DecoratedBox)) + .map((d) => (d.decoration as BoxDecoration).color?.toARGB32()) + .whereType() + .toSet(); + + expect(colors, hasLength(1)); + }); + + testWidgets('applies fallbackColorAlpha to the background', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: 'gaming', + size: 40, + fallbackColorAlpha: 0.2, + ), + ), + ); + + expect(paintedColorBehind(tester, find.text('G')).a, closeTo(0.2, 0.01)); + }); + + testWidgets('fills exactly size x size', (tester) async { + await tester.pumpWidget( + wrap(const CommunityAvatar(name: 'gaming', size: 40)), + ); + + expect( + tester.getSize( + find.ancestor( + of: find.text('G'), + matching: find.byType(DecoratedBox), + ).first, + ), + const Size(40, 40), + ); + }); + + testWidgets('circle shape paints a circular box', (tester) async { + await tester.pumpWidget( + wrap(const CommunityAvatar(name: 'gaming', size: 40)), + ); + + expect(decorationBehind(tester, find.text('G')).shape, BoxShape.circle); + }); + + testWidgets('roundedRect shape paints a rounded rectangle', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: 'gaming', + size: 40, + shape: CommunityAvatarShape.roundedRect, + borderRadius: 8, + ), + ), + ); + + final decoration = decorationBehind(tester, find.text('G')); + expect(decoration.shape, BoxShape.rectangle); + expect(decoration.borderRadius, BorderRadius.circular(8)); + }); + + testWidgets('an empty avatarUrl is treated as no avatar', (tester) async { + await tester.pumpWidget( + wrap(const CommunityAvatar(name: 'gaming', size: 40, avatarUrl: '')), + ); + + expect(find.text('G'), findsOneWidget); + expect(find.byType(CachedNetworkImage), findsNothing); + }); + }); + + group('CommunityAvatar image', () { + testWidgets('requests the image at the avatar size', (tester) async { + await tester.pumpWidget( + wrap( + const CommunityAvatar( + name: 'gaming', + size: 40, + avatarUrl: 'https://example.com/a.jpg', + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.imageUrl, 'https://example.com/a.jpg'); + expect(image.width, 40); + expect(image.height, 40); + }); + + for (final shape in CommunityAvatarShape.values) { + testWidgets('$shape loads without a fade, like UserAvatar', ( + tester, + ) async { + // The fade is what makes avatars flicker as list rows recycle during + // a scroll; both shapes must opt out of it, not just the circle. + await tester.pumpWidget( + wrap( + CommunityAvatar( + name: 'gaming', + size: 40, + shape: shape, + avatarUrl: 'https://example.com/a.jpg', + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.fadeInDuration, Duration.zero); + expect(image.fadeOutDuration, Duration.zero); + }); + } + }); +} diff --git a/test/widgets/count_formatting_test.dart b/test/widgets/count_formatting_test.dart new file mode 100644 index 0000000..346af04 --- /dev/null +++ b/test/widgets/count_formatting_test.dart @@ -0,0 +1,123 @@ +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/widgets/comments_header.dart'; +import 'package:coves_flutter/widgets/post_action_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Every count in the UI is expected to render through the one canonical +/// formatter (`DisplayUtils.formatCount`): uppercase K/M, with a real M tier. +/// +/// These two widgets need no providers, so they pin the shared contract +/// cheaply. PostCardActions and CommentCard cover the same contract inside +/// their existing provider harnesses (post_card_test.dart, +/// comment_thread_test.dart). +void main() { + FeedViewPost createPost({required int score, required int commentCount}) { + return FeedViewPost( + post: PostView( + uri: 'at://did:example/post/123', + cid: 'cid123', + rkey: '123', + author: AuthorView(did: 'did:plc:author', handle: 'author.test'), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + stats: PostStats( + upvotes: score, + downvotes: 0, + score: score, + commentCount: commentCount, + ), + ), + ); + } + + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + group('PostActionBar count formatting', () { + testWidgets('renders a millions-scale vote score with an uppercase M', ( + tester, + ) async { + await tester.pumpWidget( + wrap(PostActionBar(post: createPost(score: 1500000, commentCount: 0))), + ); + + expect(find.text('1.5M'), findsOneWidget); + // The old formatter had no M tier and used a lowercase suffix. + expect(find.text('1500.0k'), findsNothing); + }); + + testWidgets('renders a thousands-scale vote score with an uppercase K', ( + tester, + ) async { + await tester.pumpWidget( + wrap(PostActionBar(post: createPost(score: 5234, commentCount: 0))), + ); + + expect(find.text('5.2K'), findsOneWidget); + expect(find.text('5.2k'), findsNothing); + }); + + testWidgets('renders the comment count with an uppercase K', ( + tester, + ) async { + await tester.pumpWidget( + wrap(PostActionBar(post: createPost(score: 0, commentCount: 12500))), + ); + + expect(find.text('12.5K'), findsOneWidget); + expect(find.text('12.5k'), findsNothing); + }); + + testWidgets('renders small counts unchanged', (tester) async { + await tester.pumpWidget( + wrap(PostActionBar(post: createPost(score: 8, commentCount: 5))), + ); + + expect(find.text('8'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }); + }); + + group('CommentsHeader count formatting', () { + Widget header(int count) => wrap( + CommentsHeader( + commentCount: count, + currentSort: 'hot', + onSortChanged: (_) {}, + ), + ); + + testWidgets('formats a large comment count', (tester) async { + await tester.pumpWidget(header(5234)); + + expect(find.text('5.2K Comments'), findsOneWidget); + expect(find.text('5234 Comments'), findsNothing); + }); + + testWidgets('formats a millions-scale comment count', (tester) async { + await tester.pumpWidget(header(1500000)); + + expect(find.text('1.5M Comments'), findsOneWidget); + }); + + testWidgets('leaves small counts unformatted and singular at 1', ( + tester, + ) async { + await tester.pumpWidget(header(1)); + expect(find.text('1 Comment'), findsOneWidget); + + await tester.pumpWidget(header(42)); + expect(find.text('42 Comments'), findsOneWidget); + }); + + testWidgets('renders the empty state at zero', (tester) async { + await tester.pumpWidget(header(0)); + + expect(find.text('No comments yet'), findsOneWidget); + }); + }); +} diff --git a/test/widgets/feed_page_test.dart b/test/widgets/feed_page_test.dart new file mode 100644 index 0000000..8417165 --- /dev/null +++ b/test/widgets/feed_page_test.dart @@ -0,0 +1,176 @@ +// Characterization tests for FeedPage's anti-jitter invariants. +// +// These PASS against the current lib/widgets/feed_page.dart and exist as +// guard rails for the pagination de-duplication work: if FeedPage adopts +// PaginatedSliverList, these four properties must survive the swap. +// +// 1. the footer slot is always reserved while posts are non-empty, so the +// child count never fluctuates during pagination (feed_page.dart:62-65, +// :243) +// 2. the footer carries the stable ValueKey('feed_footer') (:203-208) +// 3. the idle footer reserves exactly 80px (:343-346) +// 4. findChildIndexCallback maps post URIs and the footer (:244-261) + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/providers/multi_feed_provider.dart'; +import 'package:coves_flutter/widgets/feed_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/fake_providers.dart'; + +FeedViewPost buildPost(String id) { + return FeedViewPost( + post: PostView( + uri: 'at://did:plc:test/social.coves.community.post/$id', + cid: 'cid-$id', + rkey: id, + author: AuthorView( + did: 'did:plc:author', + handle: 'test.user', + displayName: 'Test User', + ), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + handle: 'test-community.community.coves.social', + ), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: PostRecord(content: 'Body $id', title: 'Post $id', facets: []), + stats: PostStats(score: 1, upvotes: 1, downvotes: 0, commentCount: 0), + ), + ); +} + +void main() { + late FakeAuthProvider auth; + late ScrollController scrollController; + + setUp(() { + auth = FakeAuthProvider(); + scrollController = ScrollController(); + }); + + tearDown(() { + scrollController.dispose(); + auth.dispose(); + }); + + Widget host({ + required List posts, + bool isLoadingMore = false, + bool hasMore = true, + String? error, + }) { + return MultiProvider( + providers: postCardProviders(auth: auth), + child: MaterialApp( + home: Scaffold( + body: FeedPage( + feedType: FeedType.discover, + posts: posts, + isLoading: false, + isLoadingMore: isLoadingMore, + hasMore: hasMore, + error: error, + scrollController: scrollController, + onRefresh: () async {}, + onRetry: () {}, + onClearErrorAndLoadMore: () {}, + isAuthenticated: false, + currentTime: DateTime.parse('2025-01-01T13:00:00Z'), + ), + ), + ), + ); + } + + SliverChildBuilderDelegate delegateOf(WidgetTester tester) { + final adaptor = tester.widget( + find.byWidgetPredicate((w) => w is SliverMultiBoxAdaptorWidget), + ); + return adaptor.delegate as SliverChildBuilderDelegate; + } + + const footerKey = ValueKey('feed_footer'); + + testWidgets('reserves a footer slot while posts are non-empty', ( + tester, + ) async { + final posts = [buildPost('a'), buildPost('b')]; + + await tester.pumpWidget(host(posts: posts)); + + expect(delegateOf(tester).estimatedChildCount, posts.length + 1); + expect(find.byKey(footerKey), findsOneWidget); + }); + + testWidgets('child count is stable across isLoadingMore toggles', ( + tester, + ) async { + final posts = [buildPost('a'), buildPost('b')]; + + await tester.pumpWidget(host(posts: posts)); + final idle = delegateOf(tester).estimatedChildCount; + + await tester.pumpWidget(host(posts: posts, isLoadingMore: true)); + final loading = delegateOf(tester).estimatedChildCount; + + await tester.pumpWidget(host(posts: posts)); + + expect(loading, idle); + expect(delegateOf(tester).estimatedChildCount, idle); + }); + + testWidgets('the idle footer reserves exactly 80px', (tester) async { + await tester.pumpWidget(host(posts: [buildPost('a')])); + + expect(tester.getSize(find.byKey(footerKey)).height, 80.0); + }); + + testWidgets('the footer becomes a spinner while loading more, without ' + 'changing the child count', (tester) async { + final posts = [buildPost('a')]; + + await tester.pumpWidget(host(posts: posts, isLoadingMore: true)); + + expect(delegateOf(tester).estimatedChildCount, 2); + expect( + find.descendant( + of: find.byKey(footerKey), + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, + ); + }); + + testWidgets('findChildIndexCallback maps post URIs and the footer', ( + tester, + ) async { + final posts = [ + buildPost('a'), + buildPost('b'), + buildPost('c'), + ]; + + await tester.pumpWidget(host(posts: posts)); + + final indexOfKey = delegateOf(tester).findChildIndexCallback!; + + expect(indexOfKey(ValueKey(posts[0].post.uri)), 0); + expect(indexOfKey(ValueKey(posts[2].post.uri)), 2); + expect(indexOfKey(footerKey), posts.length); + expect(indexOfKey(const ValueKey('at://unknown')), isNull); + expect(indexOfKey(const ValueKey(0)), isNull); + }); + + testWidgets('each post is keyed by its URI', (tester) async { + final posts = [buildPost('a')]; + + await tester.pumpWidget(host(posts: posts)); + + expect(find.byKey(ValueKey(posts[0].post.uri)), findsOneWidget); + }); +} diff --git a/test/widgets/media/favicon_test.dart b/test/widgets/media/favicon_test.dart new file mode 100644 index 0000000..f18c4cf --- /dev/null +++ b/test/widgets/media/favicon_test.dart @@ -0,0 +1,95 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/widgets/media/favicon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The `domain` a [Favicon] renders comes straight off an AppView record, so +/// it is attacker-influenced text that must not be able to reach into the +/// favicon-service query string and add or overwrite parameters. +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + String imageUrlOf(WidgetTester tester) { + return tester + .widget(find.byType(CachedNetworkImage)) + .imageUrl; + } + + group('Favicon url building', () { + testWidgets('builds the canonical query for an ordinary domain', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const Favicon('https://example.com/a', domain: 'example.com')), + ); + + expect( + imageUrlOf(tester), + 'https://www.google.com/s2/favicons?domain=example.com&sz=32', + ); + }); + + testWidgets('percent-encodes a domain that smuggles a query parameter', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const Favicon( + 'https://example.com/a', + domain: 'evil.com&sz=999', + ), + ), + ); + + final url = imageUrlOf(tester); + expect( + url, + 'https://www.google.com/s2/favicons?domain=evil.com%26sz%3D999&sz=32', + ); + expect( + Uri.parse(url).queryParameters['domain'], + 'evil.com&sz=999', + reason: 'the hostile text stays one value, not two parameters', + ); + expect( + Uri.parse(url).queryParameters['sz'], + '32', + reason: 'the caller-controlled sz must not be overridden', + ); + }); + + testWidgets('encodes whitespace and other unsafe characters', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const Favicon('https://example.com/a', domain: 'bad domain/#?'), + ), + ); + + final url = imageUrlOf(tester); + expect(url.contains(' '), isFalse); + expect(Uri.parse(url).queryParameters['domain'], 'bad domain/#?'); + }); + + testWidgets('falls back to the host parsed out of the url', (tester) async { + await tester.pumpWidget( + wrap(const Favicon('https://sub.example.com/path?q=1')), + ); + + expect( + imageUrlOf(tester), + 'https://www.google.com/s2/favicons?domain=sub.example.com&sz=32', + ); + }); + + testWidgets('renders the link glyph when there is no domain at all', ( + tester, + ) async { + await tester.pumpWidget(wrap(const Favicon('not a url'))); + + expect(find.byType(CachedNetworkImage), findsNothing); + expect(find.byIcon(Icons.link), findsOneWidget); + }); + }); +} diff --git a/test/widgets/media/media_aspect_test.dart b/test/widgets/media/media_aspect_test.dart new file mode 100644 index 0000000..0a0768c --- /dev/null +++ b/test/widgets/media/media_aspect_test.dart @@ -0,0 +1,124 @@ +// Spec for the shared media aspect-ratio clamp. +// +// The feed card and the detail view clamp a record's declared aspect ratio to +// DIFFERENT bounds, on purpose: the feed keeps a scannable card shape, the +// detail view preserves nearly the true proportions and clamps only as a +// safety rail (the backend never validates `aspectRatio`, so a hostile record +// can declare 1:1000000). One function, two bound sets. +// +// Target API — lib/widgets/media/media_aspect.dart: +// +// typedef MediaRatioBounds = ({double min, double max}); +// const MediaRatioBounds kFeedRatioBounds = (min: 3 / 4, max: 16 / 9); +// const MediaRatioBounds kDetailRatioBounds = (min: 1 / 3, max: 3); +// double clampMediaRatio( +// EmbedAspectRatio? ratio, { +// required double min, +// required double max, +// double fallback = 16 / 9, +// }); +// +// All ratios are width/height. +// +// COMPILE-RED until lib/widgets/media/media_aspect.dart exists. + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/widgets/media/media_aspect.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Calls the clamp with the feed's bounds, the way post_card.dart will. +double feedRatio(EmbedAspectRatio? ratio) => clampMediaRatio( + ratio, + min: kFeedRatioBounds.min, + max: kFeedRatioBounds.max, +); + +/// Calls the clamp with the detail view's bounds. +double detailRatio(EmbedAspectRatio? ratio) => clampMediaRatio( + ratio, + min: kDetailRatioBounds.min, + max: kDetailRatioBounds.max, +); + +void main() { + group('bound constants', () { + test('the feed clamps to 3:4 .. 16:9', () { + expect(kFeedRatioBounds.min, 3 / 4); + expect(kFeedRatioBounds.max, 16 / 9); + }); + + test('the detail view clamps to 1:3 .. 3:1', () { + expect(kDetailRatioBounds.min, 1 / 3); + expect(kDetailRatioBounds.max, 3); + }); + }); + + group('clampMediaRatio with no declared ratio', () { + test('falls back to 16:9 by default', () { + expect(feedRatio(null), 16 / 9); + expect(detailRatio(null), 16 / 9); + }); + + test('honours an explicit fallback', () { + expect(clampMediaRatio(null, min: 1 / 3, max: 3, fallback: 1), 1.0); + }); + + test('does not clamp the fallback into the bounds', () { + // The fallback is a deliberate display choice, not record data; it is + // returned as given so a caller can pick a shape outside its own rails. + expect(clampMediaRatio(null, min: 1, max: 1.2, fallback: 16 / 9), 16 / 9); + }); + }); + + group('clampMediaRatio with feed bounds', () { + test('passes an in-range ratio through untouched', () { + expect(feedRatio(EmbedAspectRatio(width: 4, height: 3)), 4 / 3); + expect(feedRatio(EmbedAspectRatio(width: 1, height: 1)), 1.0); + }); + + test('keeps the exact bound values', () { + expect(feedRatio(EmbedAspectRatio(width: 16, height: 9)), 16 / 9); + expect(feedRatio(EmbedAspectRatio(width: 3, height: 4)), 3 / 4); + }); + + test('clamps a panorama down to 16:9', () { + expect(feedRatio(EmbedAspectRatio(width: 21, height: 9)), 16 / 9); + expect(feedRatio(EmbedAspectRatio(width: 4000, height: 1)), 16 / 9); + }); + + test('clamps a tall portrait up to 3:4', () { + // A 9:16 story screenshot gets center-cropped rather than swallowing + // the viewport. + expect(feedRatio(EmbedAspectRatio(width: 9, height: 16)), 3 / 4); + expect(feedRatio(EmbedAspectRatio(width: 1, height: 1000000)), 3 / 4); + }); + }); + + group('clampMediaRatio with detail bounds', () { + test('lets a 9:16 portrait through uncropped', () { + expect(detailRatio(EmbedAspectRatio(width: 9, height: 16)), 9 / 16); + }); + + test('lets a 3:1 panorama through uncropped', () { + expect(detailRatio(EmbedAspectRatio(width: 3, height: 1)), 3.0); + }); + + test('clamps a hostile ratio to the safety rails', () { + expect(detailRatio(EmbedAspectRatio(width: 1, height: 1000000)), 1 / 3); + expect(detailRatio(EmbedAspectRatio(width: 1000000, height: 1)), 3.0); + }); + + test('always returns a finite, positive ratio', () { + final extremes = [ + EmbedAspectRatio(width: 1, height: 1000000), + EmbedAspectRatio(width: 1000000, height: 1), + EmbedAspectRatio(width: 1, height: 1), + ]; + for (final ratio in extremes) { + final value = detailRatio(ratio); + expect(value.isFinite, isTrue, reason: 'AspectRatio would assert'); + expect(value, greaterThan(0)); + } + }); + }); +} diff --git a/test/widgets/media/media_format_test.dart b/test/widgets/media/media_format_test.dart new file mode 100644 index 0000000..2b6c936 --- /dev/null +++ b/test/widgets/media/media_format_test.dart @@ -0,0 +1,43 @@ +// Spec for `formatVideoDuration` in its new home. +// +// The function is currently declared in lib/widgets/post_card.dart and +// imported from there by detailed_post_view.dart — a feed widget is the wrong +// home for a pure formatter that both surfaces use. This file pins the +// behaviour against the new import path; the copy in +// test/widgets/post_card_media_test.dart (group 'formatVideoDuration', B5) +// moves to this import once the function does. +// +// COMPILE-RED until lib/widgets/media/media_format.dart exists. + +import 'package:coves_flutter/widgets/media/media_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatVideoDuration', () { + test('formats sub-minute durations as m:ss', () { + expect(formatVideoDuration(0), '0:00'); + expect(formatVideoDuration(5), '0:05'); + expect(formatVideoDuration(42), '0:42'); + expect(formatVideoDuration(59), '0:59'); + }); + + test('formats minute durations as m:ss zero-padded', () { + expect(formatVideoDuration(60), '1:00'); + expect(formatVideoDuration(61), '1:01'); + expect(formatVideoDuration(754), '12:34'); + expect(formatVideoDuration(3599), '59:59'); + }); + + test('switches to h:mm:ss at one hour', () { + expect(formatVideoDuration(3600), '1:00:00'); + expect(formatVideoDuration(3723), '1:02:03'); + expect(formatVideoDuration(7325), '2:02:05'); + }); + + test('treats negative input as zero rather than throwing', () { + // The value comes from an untrusted record, so the function is total. + expect(formatVideoDuration(-1), '0:00'); + expect(formatVideoDuration(-3600), '0:00'); + }); + }); +} diff --git a/test/widgets/media/native_image_embed_test.dart b/test/widgets/media/native_image_embed_test.dart new file mode 100644 index 0000000..e5385d2 --- /dev/null +++ b/test/widgets/media/native_image_embed_test.dart @@ -0,0 +1,80 @@ +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/widgets/media/native_image_embed.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Both native-image widgets index `images.first`. The invariant that makes +/// that safe lives in [ImagesPostEmbed], one layer up — these asserts pin it +/// at the widget boundary so a future caller that hand-rolls the list fails +/// loudly in debug instead of throwing a bare StateError out of `first`. +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + EmbedImage image() => const EmbedImage( + thumb: 'https://cdn.test/thumb.jpg', + fullsize: 'https://cdn.test/full.jpg', + ); + + group('empty-gallery guard', () { + testWidgets('NativeImageThumb asserts on an empty image list', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const NativeImageThumb(images: [], keyPrefix: 'post')), + ); + + final error = tester.takeException(); + expect(error, isA()); + expect(error.toString(), contains('ImagesPostEmbed')); + }); + + testWidgets('NativeImageGallery asserts on an empty image list', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + NativeImageGallery( + images: const [], + keyPrefix: 'detail', + onOpen: (_) {}, + ), + ), + ); + + final error = tester.takeException(); + expect(error, isA()); + expect(error.toString(), contains('ImagesPostEmbed')); + }); + + testWidgets('a one-image list renders normally', (tester) async { + await tester.pumpWidget( + wrap(NativeImageThumb(images: [image()], keyPrefix: 'post')), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + wrap( + NativeImageGallery( + images: [image()], + keyPrefix: 'detail', + onOpen: (_) {}, + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); + + test('ImagesPostEmbed is the guarantor of the invariant', () { + // The widgets' asserts are a debug backstop; the real enforcement is the + // model constructor, which throws in release too. + expect( + () => ImagesPostEmbed( + type: 'app.coves.embed.images', + images: const [], + data: const {}, + ), + throwsArgumentError, + ); + }); +} diff --git a/test/widgets/media/streamable_flow_test.dart b/test/widgets/media/streamable_flow_test.dart new file mode 100644 index 0000000..39638d8 --- /dev/null +++ b/test/widgets/media/streamable_flow_test.dart @@ -0,0 +1,463 @@ +// Behavioural spec for the Streamable launch flow that the feed card and the +// detail view are about to share (see the media extraction brief, bugs 1-3). +// +// These tests run against the CURRENT widgets — `PostCard` and +// `DetailedPostView` — deliberately, not against the not-yet-extracted shared +// widget: they pin behaviour the user can observe, so they must keep passing +// once `StreamableVideoEmbed` takes over both call sites. +// +// Red-phase expectations (see the report): +// M1 feed treats `embedType` case-insensitively — currently FAILS +// M2 feed's failure snackbar reads 'Could not load video' — currently FAILS +// M3b detail's play button reports `enabled: false` while loading — FAILS +// The remaining cases characterise behaviour that already holds and must +// survive the extraction. + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/widgets/detailed_post_view.dart'; +import 'package:coves_flutter/widgets/fullscreen_video_player.dart'; +import 'package:coves_flutter/widgets/post_card.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/fake_providers.dart'; + +const _streamableUri = 'https://streamable.com/abc123'; +const _streamableApi = 'https://api.streamable.com/videos/abc123'; +const _thumb = 'https://cdn.test/video-thumb.jpg'; +const _resolvedVideoUrl = 'https://cdn.test/resolved.mp4'; + +/// The one snackbar the app shows when a Streamable URL cannot be resolved. +/// The two call sites disagree today ('Failed to load video' on the feed); +/// the shared widget standardises on this string. +const _failureCopy = 'Could not load video'; + +/// Records routes pushed onto the navigator so a `Navigator.push` can be +/// inspected *without* mounting the pushed page: mounting +/// [FullscreenVideoPlayer] hits the video_player platform channel, which +/// throws UnimplementedError under flutter_test. +class _RecordingObserver extends NavigatorObserver { + final List> pushed = >[]; + + @override + void didPush(Route route, Route? previousRoute) { + pushed.add(route); + super.didPush(route, previousRoute); + } + + /// Routes pushed by a user interaction. MaterialApp's own initial route is + /// a `MaterialPageRoute` and Dart treats `dynamic` and `void` as + /// mutual subtypes, so callers reset() after the initial pump. + List> get pushedPages => + pushed.whereType>().toList(); + + void reset() => pushed.clear(); +} + +/// A Streamable service whose one request resolves after [delay]. +/// +/// With [videoUrl] null the reply carries no `files` entry, so `getVideoUrl` +/// resolves to null and the widget takes the snackbar branch. The delay is +/// what makes the loading state observable before the request settles. +StreamableService mockedStreamable({ + String? videoUrl, + Duration delay = const Duration(milliseconds: 500), +}) { + final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com')); + + DioAdapter(dio: dio).onGet( + _streamableApi, + (server) => server.reply( + 200, + videoUrl == null + ? {} + : { + 'files': { + 'mp4': {'url': videoUrl}, + }, + }, + delay: delay, + ), + ); + + return StreamableService(dio: dio); +} + +FeedViewPost makePost({required ExternalEmbed external}) { + return FeedViewPost( + post: PostView( + uri: 'at://did:example/post/123', + cid: 'cid123', + rkey: '123', + author: AuthorView(did: 'did:plc:author', handle: 'author.test'), + community: CommunityRef(did: 'did:plc:community', name: 'test-community'), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + record: const PostRecord(title: 'Test Post Title'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: ExternalPostEmbed( + type: 'social.coves.embed.external', + external: external, + data: const {}, + ), + ), + ); +} + +ExternalEmbed streamableEmbed({ + String? embedType = 'video', + String? provider = 'streamable', + String? thumb = _thumb, +}) { + return ExternalEmbed( + uri: _streamableUri, + thumb: thumb, + embedType: embedType, + provider: provider, + ); +} + +void main() { + late FakeAuthProvider auth; + late _RecordingObserver observer; + + setUp(() { + auth = FakeAuthProvider(); + observer = _RecordingObserver(); + }); + + /// A phone-sized surface: the default 800x600 test view is too short for a + /// media block plus header and actions, and the resulting RenderFlex + /// overflow would fail tests for the wrong reason. + void useMediaSizedSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + // PostCard's subtree navigates with go_router (TappableAuthor, + // TappableCommunity, _navigateToDetail), so it needs a router rather than + // a bare MaterialApp. + Widget feedHarness(FeedViewPost post, {StreamableService? streamable}) { + final router = GoRouter( + observers: [observer], + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: PostCard(post: post)), + ), + GoRoute( + path: '/post/:uri', + builder: (context, state) => const Scaffold(body: Text('DETAIL')), + ), + ], + ); + addTearDown(router.dispose); + + return MultiProvider( + providers: postCardProviders(auth: auth, streamableService: streamable), + child: MaterialApp.router(routerConfig: router), + ); + } + + Widget detailHarness(FeedViewPost post, {StreamableService? streamable}) { + return MultiProvider( + providers: [ + Provider.value( + value: streamable ?? StreamableService(), + ), + ], + child: MaterialApp( + navigatorObservers: [observer], + home: Scaffold( + body: SingleChildScrollView(child: DetailedPostView(post: post)), + ), + ), + ); + } + + group('M1 feed Streamable detection is case-insensitive', () { + testWidgets('shows the play button for embedType "Video"', (tester) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness(makePost(external: streamableEmbed(embedType: 'Video'))), + ); + await tester.pump(); + + expect( + find.byIcon(Icons.play_arrow), + findsOneWidget, + reason: + 'embedType casing is provider-supplied metadata; "Video" is the ' + 'same embed as "video"', + ); + }); + + testWidgets('shows the play button for embedType "VIDEO-STREAM"', ( + tester, + ) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness( + makePost(external: streamableEmbed(embedType: 'VIDEO-STREAM')), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('shows the play button for provider "Streamable"', ( + tester, + ) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness( + makePost(external: streamableEmbed(provider: 'Streamable')), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('still shows the play button for lowercase "video"', ( + tester, + ) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness(makePost(external: streamableEmbed())), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('leaves a non-video embed alone whatever its casing', ( + tester, + ) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness(makePost(external: streamableEmbed(embedType: 'Article'))), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsNothing); + }); + + testWidgets('leaves a non-Streamable video provider alone', (tester) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness( + makePost( + external: streamableEmbed(embedType: 'Video', provider: 'YouTube'), + ), + ), + ); + await tester.pump(); + + expect( + find.byIcon(Icons.play_arrow), + findsNothing, + reason: 'only Streamable URLs can be resolved to an MP4 in-app', + ); + }); + }); + + group('M2 feed Streamable launch', () { + testWidgets('reports a failed resolve with the shared copy', ( + tester, + ) async { + useMediaSizedSurface(tester); + + await tester.pumpWidget( + feedHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect( + find.text(_failureCopy), + findsOneWidget, + reason: 'both surfaces must say the same thing when a resolve fails', + ); + + // Drain the snackbar so no timer is left pending at teardown. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + + testWidgets('marks the play button disabled while resolving', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + feedHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: false, + ), + reason: 'a tap handler that is null must not advertise as actionable', + ); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + handle.dispose(); + }); + }); + + group('M3 detail Streamable launch', () { + testWidgets('shows a loading indicator while resolving', (tester) async { + await tester.pumpWidget( + detailHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); + + await tester.tap(find.byIcon(Icons.play_arrow_rounded)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byIcon(Icons.play_arrow_rounded), findsNothing); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + + testWidgets('marks the play button disabled while resolving', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + detailHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow_rounded)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: false, + ), + reason: + 'the detail view drops its tap handler while loading but still ' + 'advertises an enabled button', + ); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + handle.dispose(); + }); + + testWidgets('shows the shared failure copy when the resolve returns null', ( + tester, + ) async { + await tester.pumpWidget( + detailHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow_rounded)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.text(_failureCopy), findsOneWidget); + + // The button comes back: a failed resolve is retryable. + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + + testWidgets('pushes the fullscreen player with the resolved url', ( + tester, + ) async { + await tester.pumpWidget( + detailHarness( + makePost(external: streamableEmbed()), + streamable: mockedStreamable(videoUrl: _resolvedVideoUrl), + ), + ); + await tester.pump(); + observer.reset(); + + await tester.tap(find.byIcon(Icons.play_arrow_rounded)); + // One pump to enter the loading state, one timed pump to settle the + // mocked request. The route is recorded on push; deliberately not + // pumping again, which would mount FullscreenVideoPlayer and hit the + // video_player platform channel. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final routes = observer.pushedPages; + expect(routes, hasLength(1)); + + final page = routes.single.builder(tester.element(find.byType(Scaffold))); + expect(page, isA()); + expect((page as FullscreenVideoPlayer).videoUrl, _resolvedVideoUrl); + + expect( + find.text(_failureCopy), + findsNothing, + reason: 'a successful resolve must not also report an error', + ); + }); + }); +} diff --git a/test/widgets/media/streamable_video_embed_test.dart b/test/widgets/media/streamable_video_embed_test.dart new file mode 100644 index 0000000..82239fd --- /dev/null +++ b/test/widgets/media/streamable_video_embed_test.dart @@ -0,0 +1,429 @@ +// Spec for the shared Streamable embed extracted from post_card.dart's +// `_EmbedCard` and detailed_post_view.dart's `_VideoEmbed`. +// +// Target API — lib/widgets/media/streamable_video_embed.dart: +// +// enum PlayChipStyle { feed, detail } +// +// class StreamableVideoEmbed extends StatefulWidget { +// const StreamableVideoEmbed({ +// required this.embed, // ExternalEmbed +// required this.streamableService, +// this.height = 180, +// this.frameDecoration, // BoxDecoration? (feed border) +// this.darken = false, // detail's scrim +// this.playChipStyle = PlayChipStyle.feed, +// super.key, +// }); +// +// /// Case-INSENSITIVE: `embedType` in {video, video-stream} and +// /// `provider` == streamable, whatever the casing the AppView sent. +// static bool isStreamableVideo(ExternalEmbed embed); +// } +// +// The widget folds the three bugs the two copies disagreed on: +// 1. case-insensitive embedType (was feed-only, case-sensitive) +// 2. `Semantics(enabled: !loading)` (was feed-only) +// 3. one snackbar string, 'Could not load video' +// +// COMPILE-RED until lib/widgets/media/streamable_video_embed.dart exists. + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/widgets/media/streamable_video_embed.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../../test_helpers/mock_url_launcher_platform.dart'; + +const _streamableUri = 'https://streamable.com/abc123'; +const _streamableApi = 'https://api.streamable.com/videos/abc123'; +const _thumb = 'https://cdn.test/video-thumb.jpg'; +const _failureCopy = 'Could not load video'; + +ExternalEmbed embedWith({ + String? embedType = 'video', + String? provider = 'streamable', + String? thumb = _thumb, + String uri = _streamableUri, +}) { + return ExternalEmbed( + uri: uri, + thumb: thumb, + embedType: embedType, + provider: provider, + ); +} + +/// A service whose single request resolves after a delay with no `files` +/// entry, so `getVideoUrl` yields null and the widget takes the error branch. +StreamableService failingStreamable() { + final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com')); + DioAdapter(dio: dio).onGet( + _streamableApi, + (server) => server.reply( + 200, + {}, + delay: const Duration(milliseconds: 500), + ), + ); + return StreamableService(dio: dio); +} + +/// A service whose request answers with a `files` field of the wrong shape, +/// so the production cast inside `getVideoUrl` throws a [TypeError] — the +/// non-Dio failure mode that used to escape the widget entirely. +StreamableService malformedStreamable() { + final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com')); + DioAdapter(dio: dio).onGet( + _streamableApi, + (server) => server.reply(200, {'files': 'not-a-map'}), + ); + return StreamableService(dio: dio); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockUrlLauncherPlatform mockPlatform; + + setUp(() { + mockPlatform = MockUrlLauncherPlatform(); + UrlLauncherPlatform.instance = mockPlatform; + }); + + Widget harness(Widget child) { + return MaterialApp(home: Scaffold(body: child)); + } + + group('isStreamableVideo', () { + test('accepts the canonical lowercase form', () { + expect(StreamableVideoEmbed.isStreamableVideo(embedWith()), isTrue); + expect( + StreamableVideoEmbed.isStreamableVideo( + embedWith(embedType: 'video-stream'), + ), + isTrue, + ); + }); + + test('ignores embedType casing', () { + for (final type in [ + 'Video', + 'VIDEO', + 'Video-Stream', + 'VIDEO-STREAM', + ]) { + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(embedType: type)), + isTrue, + reason: '$type is the same embed type as its lowercase form', + ); + } + }); + + test('ignores provider casing', () { + for (final provider in [ + 'streamable', + 'Streamable', + 'STREAMABLE', + ]) { + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(provider: provider)), + isTrue, + ); + } + }); + + test('rejects a non-video embed type', () { + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(embedType: 'article')), + isFalse, + ); + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(embedType: null)), + isFalse, + ); + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(embedType: 'videos')), + isFalse, + reason: 'the match is exact after lowercasing, not a prefix test', + ); + }); + + test('rejects a video from another provider', () { + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(provider: 'youtube')), + isFalse, + ); + expect( + StreamableVideoEmbed.isStreamableVideo(embedWith(provider: null)), + isFalse, + reason: 'only Streamable URLs can be resolved to an MP4 in-app', + ); + }); + }); + + group('rendering', () { + testWidgets('renders nothing without a thumbnail', (tester) async { + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(thumb: null), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsNothing); + expect(find.byIcon(Icons.play_arrow_rounded), findsNothing); + }); + + testWidgets('the feed chip style uses the square play glyph', ( + tester, + ) async { + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + expect(find.byIcon(Icons.play_arrow_rounded), findsNothing); + }); + + testWidgets('the detail chip style uses the rounded play glyph', ( + tester, + ) async { + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + playChipStyle: PlayChipStyle.detail, + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsNothing); + }); + + testWidgets('applies the caller-supplied frame decoration', (tester) async { + const decoration = BoxDecoration(color: Color(0xFF00FF00)); + + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + frameDecoration: decoration, + ), + ), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => widget is Container && widget.decoration == decoration, + ), + findsOneWidget, + reason: 'the feed keeps its bordered frame via frameDecoration', + ); + }); + + testWidgets('advertises an enabled play button before any tap', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics(isButton: true, isEnabled: true), + ); + handle.dispose(); + }); + }); + + group('launch flow', () { + testWidgets('shows a loading chip and a disabled button while resolving', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsNothing); + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: false, + ), + ); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + handle.dispose(); + }); + + testWidgets('shows the shared failure copy and restores the button', ( + tester, + ) async { + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.text(_failureCopy), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + find.byIcon(Icons.play_arrow), + findsOneWidget, + reason: 'a failed resolve is retryable', + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + + testWidgets('reports an unexpected response shape instead of hanging', ( + tester, + ) async { + // A TypeError out of the resolve used to escape the try/finally: the + // spinner stopped and the user was told nothing. + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: embedWith(), + streamableService: malformedStreamable(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text(_failureCopy), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + find.byIcon(Icons.play_arrow), + findsOneWidget, + reason: 'the loading flag is reset so the chip is retryable', + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + }); + + group('non-Streamable video embeds', () { + // The detail view renders this widget for ANY video-typed embed, so a + // YouTube/Vimeo link reaches the tap handler with nothing to resolve + // in-app. The chip is still a live control: it hands off to the browser. + ExternalEmbed youtubeEmbed() => embedWith( + provider: 'youtube', + uri: 'https://www.youtube.com/watch?v=abc123', + ); + + testWidgets('advertises an enabled play button', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: youtubeEmbed(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics(isButton: true, isEnabled: true), + ); + handle.dispose(); + }); + + testWidgets('opens the link externally rather than doing nothing', ( + tester, + ) async { + await tester.pumpWidget( + harness( + StreamableVideoEmbed( + embed: youtubeEmbed(), + streamableService: failingStreamable(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + mockPlatform.launchedUrls, + contains('https://www.youtube.com/watch?v=abc123'), + ); + expect( + find.text(_failureCopy), + findsNothing, + reason: 'handing off to the browser is a success, not a failure', + ); + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: 'there is no in-app resolve to wait on', + ); + }); + }); +} diff --git a/test/widgets/paginated_sliver_list_test.dart b/test/widgets/paginated_sliver_list_test.dart new file mode 100644 index 0000000..b6b4339 --- /dev/null +++ b/test/widgets/paginated_sliver_list_test.dart @@ -0,0 +1,521 @@ +// RED-phase spec for the shared paginated sliver. +// +// Compile-red until lib/widgets/paginated_sliver_list.dart exists. Kept +// self-contained so the rest of the suite still compiles. +// +// PaginatedSliverList carries FeedPage's four anti-jitter fixes generically +// (source: lib/widgets/feed_page.dart): +// 1. an always-reserved footer slot while items are non-empty, so the +// child count never fluctuates during pagination +// 2. a fixed 80px idle footer, matching the loading spinner's height +// 3. a stable KeyedSubtree key on the footer +// 4. a findChildIndexCallback that maps item keys and the footer +// +// The characterization of the FeedPage behaviour these are extracted from +// lives in test/widgets/feed_page_test.dart. + +import 'package:coves_flutter/widgets/loading_error_states.dart'; +import 'package:coves_flutter/widgets/paginated_sliver_list.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Item { + const _Item(this.id); + + final String id; +} + +/// A stateful tile whose counter survives a rebuild only if the element is +/// reused, which is what the item ValueKeys buy us. +class _CounterTile extends StatefulWidget { + const _CounterTile({required this.label, super.key}); + + final String label; + + @override + State<_CounterTile> createState() => _CounterTileState(); +} + +class _CounterTileState extends State<_CounterTile> { + int taps = 0; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 120, + child: GestureDetector( + onTap: () => setState(() => taps++), + child: Text('${widget.label}:$taps'), + ), + ); + } +} + +void main() { + List<_Item> items(int count) { + return List<_Item>.generate(count, (i) => _Item('id-$i')); + } + + Widget host(PaginatedSliverList<_Item> sliver) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView(slivers: [sliver]), + ), + ); + } + + PaginatedSliverList<_Item> buildList({ + required List<_Item> data, + bool isLoadingMore = false, + bool hasMore = true, + String? loadMoreError, + String? refreshError, + VoidCallback? onRetryLoadMore, + VoidCallback? onRetryRefresh, + Widget? endOfFeedWidget, + Widget? emptyWidget, + Key? footerKey, + Widget Function(BuildContext, _Item, int)? itemBuilder, + }) { + return PaginatedSliverList<_Item>( + items: data, + isLoadingMore: isLoadingMore, + hasMore: hasMore, + loadMoreError: loadMoreError, + refreshError: refreshError, + onRetryLoadMore: onRetryLoadMore ?? () {}, + onRetryRefresh: onRetryRefresh ?? (refreshError == null ? null : () {}), + idOf: (item) => item.id, + footerKey: footerKey, + endOfFeedWidget: endOfFeedWidget, + emptyWidget: emptyWidget, + itemBuilder: + itemBuilder ?? + (context, item, index) => + SizedBox(height: 120, child: Text(item.id)), + ); + } + + SliverChildDelegate delegateOf(WidgetTester tester) { + final adaptor = tester.widget( + find.byWidgetPredicate((w) => w is SliverMultiBoxAdaptorWidget), + ); + return adaptor.delegate; + } + + int childCountOf(WidgetTester tester) { + return delegateOf(tester).estimatedChildCount!; + } + + Finder footerFinder() { + return find.byWidgetPredicate( + (w) => + w is KeyedSubtree && + w.key is ValueKey && + (w.key! as ValueKey).value.endsWith('_footer'), + ); + } + + group('stable child count', () { + testWidgets('the footer slot is reserved while items are non-empty', ( + tester, + ) async { + await tester.pumpWidget(host(buildList(data: items(3)))); + + expect(childCountOf(tester), 4); + expect(footerFinder(), findsOneWidget); + }); + + testWidgets('child count does not change when isLoadingMore toggles', ( + tester, + ) async { + await tester.pumpWidget(host(buildList(data: items(3)))); + final idle = childCountOf(tester); + + await tester.pumpWidget( + host(buildList(data: items(3), isLoadingMore: true)), + ); + final loading = childCountOf(tester); + + await tester.pumpWidget(host(buildList(data: items(3)))); + + expect(loading, idle); + expect(childCountOf(tester), idle); + }); + + testWidgets('child count does not change when a load-more error ' + 'appears', (tester) async { + await tester.pumpWidget(host(buildList(data: items(3)))); + final idle = childCountOf(tester); + + await tester.pumpWidget( + host(buildList(data: items(3), loadMoreError: 'nope')), + ); + + expect(childCountOf(tester), idle); + }); + + testWidgets('no footer slot is reserved when there are no items', ( + tester, + ) async { + await tester.pumpWidget( + host( + buildList( + data: const <_Item>[], + emptyWidget: const Text('nothing here'), + ), + ), + ); + + expect(footerFinder(), findsNothing); + expect(find.text('nothing here'), findsOneWidget); + }); + }); + + group('footer states', () { + testWidgets('the idle footer reserves exactly 80px', (tester) async { + await tester.pumpWidget(host(buildList(data: items(1)))); + + expect(tester.getSize(footerFinder()).height, 80.0); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('the idle and loading footers are exactly the same height', ( + tester, + ) async { + // The invariant behind the reserved footer slot: swapping the spacer + // for the spinner must not move the scroll offset. This is + // structural — both sides are sized from kInlineLoadingHeight — not + // a coincidence of the spinner's intrinsic size (which is 68px). + await tester.pumpWidget(host(buildList(data: items(1)))); + final idleHeight = tester.getSize(footerFinder()).height; + + await tester.pumpWidget( + host(buildList(data: items(1), isLoadingMore: true)), + ); + final loadingHeight = tester.getSize(footerFinder()).height; + + expect(idleHeight, kInlineLoadingHeight); + expect(loadingHeight, kInlineLoadingHeight); + }); + + testWidgets('the footer key ends in _footer by default', (tester) async { + await tester.pumpWidget(host(buildList(data: items(1)))); + + expect(footerFinder(), findsOneWidget); + }); + + testWidgets('a footerKey of the wrong shape is rejected', (tester) async { + // The app-wide convention: ValueKey ending in "_footer". + // findChildIndexCallback and every footer finder rely on it. + // + // Built directly rather than pumped: mounting a widget whose build + // throws buries the assertion under a "RenderViewport expected a + // RenderSliver" cascade. + late BuildContext context; + await tester.pumpWidget( + Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox.shrink(); + }, + ), + ); + + expect( + () => buildList( + data: items(1), + footerKey: const ValueKey('community_feed'), + ).build(context), + throwsAssertionError, + ); + + expect( + () => buildList( + data: items(1), + footerKey: const ValueKey(7), + ).build(context), + throwsAssertionError, + ); + + expect( + () => buildList( + data: items(1), + footerKey: const ValueKey('community_feed_footer'), + ).build(context), + returnsNormally, + ); + }); + + testWidgets('an explicit footerKey is used', (tester) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + footerKey: const ValueKey('community_feed_footer'), + ), + ), + ); + + expect( + find.byKey(const ValueKey('community_feed_footer')), + findsOneWidget, + ); + }); + + testWidgets('a spinner shows while loading more', (tester) async { + await tester.pumpWidget( + host(buildList(data: items(1), isLoadingMore: true)), + ); + + expect( + find.descendant( + of: footerFinder(), + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, + ); + }); + + testWidgets('a load-more error shows a retry that fires the callback', ( + tester, + ) async { + var retries = 0; + await tester.pumpWidget( + host( + buildList( + data: items(1), + loadMoreError: 'Network error. Check your connection.', + onRetryLoadMore: () => retries++, + ), + ), + ); + + expect( + find.text('Network error. Check your connection.'), + findsOneWidget, + ); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retries, 1); + }); + + testWidgets('the error footer wins over the end-of-feed footer', ( + tester, + ) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + hasMore: false, + loadMoreError: 'boom', + endOfFeedWidget: const Text("You're all caught up!"), + ), + ), + ); + + expect(find.text('boom'), findsOneWidget); + expect(find.text("You're all caught up!"), findsNothing); + }); + + testWidgets('the spinner wins over the error footer', (tester) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + isLoadingMore: true, + loadMoreError: 'boom', + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('boom'), findsNothing); + }); + + testWidgets('the end-of-feed widget shows when hasMore is false', ( + tester, + ) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + hasMore: false, + endOfFeedWidget: const Text("You're all caught up!"), + ), + ), + ); + + expect(find.text("You're all caught up!"), findsOneWidget); + }); + + testWidgets('the idle 80px footer is used when no endOfFeedWidget is ' + 'supplied', (tester) async { + await tester.pumpWidget( + host(buildList(data: items(1), hasMore: false)), + ); + + expect(tester.getSize(footerFinder()).height, 80.0); + }); + }); + + group('failed refresh with items on screen', () { + // Every screen gates its full-screen error on items.isEmpty, so a + // pull-to-refresh that fails with content already on screen used to + // show the user nothing at all. The footer is the one slot that is + // always reserved, so it carries the message. + testWidgets('the refresh error shows in the footer', (tester) async { + await tester.pumpWidget( + host( + buildList( + data: items(3), + refreshError: 'Network error. Check your connection.', + ), + ), + ); + + expect( + find.descendant( + of: footerFinder(), + matching: find.text('Network error. Check your connection.'), + ), + findsOneWidget, + ); + }); + + testWidgets('its retry fires onRetryRefresh, not onRetryLoadMore', ( + tester, + ) async { + var refreshRetries = 0; + var loadMoreRetries = 0; + + await tester.pumpWidget( + host( + buildList( + data: items(3), + refreshError: 'boom', + onRetryRefresh: () => refreshRetries++, + onRetryLoadMore: () => loadMoreRetries++, + ), + ), + ); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(refreshRetries, 1); + expect(loadMoreRetries, 0); + }); + + testWidgets('a refresh error does not change the child count', ( + tester, + ) async { + await tester.pumpWidget(host(buildList(data: items(3)))); + final idle = childCountOf(tester); + + await tester.pumpWidget( + host(buildList(data: items(3), refreshError: 'boom')), + ); + + expect(childCountOf(tester), idle); + }); + + testWidgets('the load-more error wins over the refresh error', ( + tester, + ) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + loadMoreError: 'pagination boom', + refreshError: 'refresh boom', + ), + ), + ); + + expect(find.text('pagination boom'), findsOneWidget); + expect(find.text('refresh boom'), findsNothing); + }); + + testWidgets('the refresh error wins over the end-of-feed footer', ( + tester, + ) async { + await tester.pumpWidget( + host( + buildList( + data: items(1), + hasMore: false, + refreshError: 'refresh boom', + endOfFeedWidget: const Text("You're all caught up!"), + ), + ), + ); + + expect(find.text('refresh boom'), findsOneWidget); + expect(find.text("You're all caught up!"), findsNothing); + }); + }); + + group('item identity', () { + testWidgets('each item is keyed by idOf and wrapped in a ' + 'RepaintBoundary', (tester) async { + await tester.pumpWidget(host(buildList(data: items(2)))); + + expect( + find.byKey(const ValueKey('id-0')), + findsOneWidget, + ); + expect( + tester.widget(find.byKey(const ValueKey('id-1'))), + isA(), + ); + }); + + testWidgets('findChildIndexCallback maps items, the footer and ' + 'unknown keys', (tester) async { + await tester.pumpWidget(host(buildList(data: items(3)))); + + final delegate = delegateOf(tester) as SliverChildBuilderDelegate; + final indexOfKey = delegate.findChildIndexCallback!; + + expect(indexOfKey(const ValueKey('id-0')), 0); + expect(indexOfKey(const ValueKey('id-2')), 2); + expect(indexOfKey(const ValueKey('nope')), isNull); + expect(indexOfKey(const ValueKey(1)), isNull); + + final footer = tester.widget(footerFinder()); + expect(indexOfKey(footer.key!), 3); + }); + + testWidgets('prepending an item preserves the existing items state', ( + tester, + ) async { + Widget tile(BuildContext context, _Item item, int index) => _CounterTile( + key: ValueKey('tile-${item.id}'), + label: item.id, + ); + + await tester.pumpWidget( + host(buildList(data: items(3), itemBuilder: tile)), + ); + + await tester.tap(find.text('id-1:0')); + await tester.pump(); + expect(find.text('id-1:1'), findsOneWidget); + + await tester.pumpWidget( + host( + buildList( + data: <_Item>[const _Item('id-new'), ...items(3)], + itemBuilder: tile, + ), + ), + ); + + // The tapped tile kept its state because it kept its key/element. + expect(find.text('id-1:1'), findsOneWidget); + }); + }); +} diff --git a/test/widgets/post_card_avatar_test.dart b/test/widgets/post_card_avatar_test.dart new file mode 100644 index 0000000..f4daf98 --- /dev/null +++ b/test/widgets/post_card_avatar_test.dart @@ -0,0 +1,158 @@ +import 'package:coves_flutter/constants/app_colors.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/utils/display_utils.dart'; +import 'package:coves_flutter/widgets/post_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/fake_providers.dart'; + +/// RED: PostCard still hand-rolls its avatars. +/// +/// Two user-visible defects are pinned here: +/// 1. The community fallback avatar paints AppColors.primary, so the same +/// community shows a different color on the feed than on the detail +/// screen (which uses the DisplayUtils hash). Everything must agree on +/// DisplayUtils.getFallbackColor. +/// 2. `community.name[0]` is unguarded, so a community with an empty name +/// throws RangeError while building the card. +void main() { + late FakeAuthProvider auth; + + setUp(() { + auth = FakeAuthProvider(); + }); + + Widget createTestWidget(FeedViewPost post, {bool showAuthorFooter = false}) { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: + (context, state) => Scaffold( + body: PostCard(post: post, showAuthorFooter: showAuthorFooter), + ), + ), + GoRoute( + path: '/post/:uri', + builder: (context, state) => const Scaffold(body: Text('DETAIL')), + ), + ], + ); + addTearDown(router.dispose); + + return MultiProvider( + providers: postCardProviders(auth: auth), + child: MaterialApp.router(routerConfig: router), + ); + } + + FeedViewPost buildPost({ + required String communityName, + String authorHandle = 'author.test', + String? authorDisplayName, + }) { + return FeedViewPost( + post: PostView( + uri: 'at://did:example/post/123', + cid: 'cid123', + rkey: '123', + author: AuthorView( + did: 'did:plc:author', + handle: authorHandle, + displayName: authorDisplayName, + ), + community: CommunityRef( + did: 'did:plc:community', + name: communityName, + ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + record: const PostRecord(content: 'body', title: 'title'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); + } + + /// Background color painted directly behind [inner]. + /// + /// Matching on DecoratedBox rather than Container keeps this agnostic to + /// whether the avatar is built with a Container, a DecoratedBox, or a + /// clipped box once the shared widget lands. + Color paintedColorBehind(WidgetTester tester, Finder inner) { + final decorated = tester.widget( + find.ancestor(of: inner, matching: find.byType(DecoratedBox)).first, + ); + return (decorated.decoration as BoxDecoration).color!; + } + + group('PostCard community avatar', () { + testWidgets('fallback uses the shared hash color, not AppColors.primary', ( + tester, + ) async { + const name = 'TestCommunity'; + // Guard: a name that happened to hash onto coral would make the + // assertion below vacuous. + expect( + DisplayUtils.getFallbackColor(name).toARGB32(), + isNot(AppColors.primary.toARGB32()), + reason: 'fixture name must hash away from the legacy color', + ); + + await tester.pumpWidget(createTestWidget(buildPost(communityName: name))); + + expect(find.text('T'), findsOneWidget); + expect( + paintedColorBehind(tester, find.text('T')).toARGB32(), + DisplayUtils.getFallbackColor(name).toARGB32(), + reason: + 'feed and detail screens must agree on the community fallback ' + 'color', + ); + }); + + testWidgets('an empty community name renders "?" without throwing', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(buildPost(communityName: ''))); + + expect( + tester.takeException(), + isNull, + reason: 'community.name[0] must be guarded against an empty name', + ); + expect(find.text('?'), findsOneWidget); + }); + }); + + group('PostCard author avatar', () { + testWidgets('fallback uses the shared hash color, not AppColors.primary', ( + tester, + ) async { + // Author avatars unify on the same hash-of-name color as every other + // avatar in the app (work brief: "Design decisions"). + const handle = 'commenter.test'; + expect( + DisplayUtils.getFallbackColor(handle).toARGB32(), + isNot(AppColors.primary.toARGB32()), + reason: 'fixture handle must hash away from the legacy color', + ); + + // The author avatar only renders in the footer variant (detail view). + await tester.pumpWidget( + createTestWidget( + buildPost(communityName: 'TestCommunity', authorHandle: handle), + showAuthorFooter: true, + ), + ); + + expect(find.text('C'), findsOneWidget); + expect( + paintedColorBehind(tester, find.text('C')).toARGB32(), + DisplayUtils.getFallbackColor(handle).toARGB32(), + ); + }); + }); +} diff --git a/test/widgets/post_card_media_test.dart b/test/widgets/post_card_media_test.dart index 796aef7..474e432 100644 --- a/test/widgets/post_card_media_test.dart +++ b/test/widgets/post_card_media_test.dart @@ -372,15 +372,14 @@ void main() { ); expect(find.text(_detailMarker), findsNothing); - final viewerImages = - tester - .widgetList( - find.descendant( - of: find.byKey(_viewerKey), - matching: find.byType(CachedNetworkImage), - ), - ) - .map((w) => w.imageUrl); + final viewerImages = tester + .widgetList( + find.descendant( + of: find.byKey(_viewerKey), + matching: find.byType(CachedNetworkImage), + ), + ) + .map((w) => w.imageUrl); expect( viewerImages, contains(_full1), @@ -424,15 +423,14 @@ void main() { ); await tester.pumpAndSettle(); - final viewerImages = - tester - .widgetList( - find.descendant( - of: find.byKey(_viewerKey), - matching: find.byType(CachedNetworkImage), - ), - ) - .map((w) => w.imageUrl); + final viewerImages = tester + .widgetList( + find.descendant( + of: find.byKey(_viewerKey), + matching: find.byType(CachedNetworkImage), + ), + ) + .map((w) => w.imageUrl); expect(viewerImages, contains(_full2)); }); @@ -908,30 +906,4 @@ void main() { expect(find.byKey(_videoKey), findsNothing); }); }); - - group('formatVideoDuration', () { - test('B5 formats sub-minute durations as m:ss', () { - expect(formatVideoDuration(0), '0:00'); - expect(formatVideoDuration(5), '0:05'); - expect(formatVideoDuration(42), '0:42'); - expect(formatVideoDuration(59), '0:59'); - }); - - test('B5 formats minute durations as m:ss zero-padded', () { - expect(formatVideoDuration(60), '1:00'); - expect(formatVideoDuration(61), '1:01'); - expect(formatVideoDuration(754), '12:34'); - expect(formatVideoDuration(3599), '59:59'); - }); - - test('B5 switches to h:mm:ss at one hour', () { - expect(formatVideoDuration(3600), '1:00:00'); - expect(formatVideoDuration(3723), '1:02:03'); - expect(formatVideoDuration(7325), '2:02:05'); - }); - - test('B5 treats negative input as zero rather than throwing', () { - expect(formatVideoDuration(-1), '0:00'); - }); - }); } diff --git a/test/widgets/post_card_test.dart b/test/widgets/post_card_test.dart index 038a0cc..68a9671 100644 --- a/test/widgets/post_card_test.dart +++ b/test/widgets/post_card_test.dart @@ -96,6 +96,41 @@ void main() { expect(find.text('5'), findsOneWidget); // comment count }); + testWidgets('formats large stats through the canonical formatter', ( + tester, + ) async { + // PostCardActions must render counts the same way every other surface + // does: uppercase K/M via DisplayUtils.formatCount. + final post = FeedViewPost( + post: PostView( + uri: 'at://did:example/post/123', + cid: 'cid123', + rkey: '123', + author: AuthorView(did: 'did:plc:author', handle: 'author.test'), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + record: const PostRecord(content: 'Test post content'), + stats: PostStats( + upvotes: 5234, + downvotes: 0, + score: 5234, + commentCount: 1500000, + ), + ), + ); + + await tester.pumpWidget(createTestWidget(post)); + + expect(find.text('5.2K'), findsOneWidget); // score + expect(find.text('1.5M'), findsOneWidget); // comment count + expect(find.text('5.2k'), findsNothing); + expect(find.text('1500.0k'), findsNothing); + }); + testWidgets('displays community avatar when available', (tester) async { final post = FeedViewPost( post: PostView( diff --git a/test/widgets/user_avatar_test.dart b/test/widgets/user_avatar_test.dart new file mode 100644 index 0000000..608426d --- /dev/null +++ b/test/widgets/user_avatar_test.dart @@ -0,0 +1,221 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/constants/app_colors.dart'; +import 'package:coves_flutter/utils/display_utils.dart'; +import 'package:coves_flutter/widgets/user_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// RED: lib/widgets/user_avatar.dart does not exist yet. +/// +/// Seven hand-rolled author/user avatars (post_card, detailed_post_view, +/// comment_card, bluesky_post_card x2, profile_header, edit_profile_screen) +/// are being unified onto this widget. This file is the spec for it and +/// fails to compile until the Green phase creates the widget — it is kept in +/// its own file so that no other test suite is compile-broken by it. +/// +/// Contract (from the work brief): +/// UserAvatar({ +/// required String name, // display name or handle: initial + hash +/// String? avatarUrl, +/// required double size, +/// Color? fallbackColor, // default DisplayUtils.getFallbackColor(name) +/// Color? fallbackTextColor, // default white +/// Widget? fallbackIcon, // icon instead of initial +/// bool showLoadingIndicator = false, +/// }) +/// Always circular. +void main() { + Widget wrap(Widget child) => + MaterialApp(home: Scaffold(body: Center(child: child))); + + BoxDecoration decorationBehind(WidgetTester tester, Finder inner) { + final decorated = tester.widget( + find.ancestor(of: inner, matching: find.byType(DecoratedBox)).first, + ); + return decorated.decoration as BoxDecoration; + } + + group('UserAvatar fallback', () { + testWidgets('renders the first letter of the name, uppercased', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const UserAvatar(name: 'ada.test', size: 24)), + ); + + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('renders "?" for an empty name instead of throwing', ( + tester, + ) async { + await tester.pumpWidget(wrap(const UserAvatar(name: '', size: 24))); + + expect(tester.takeException(), isNull); + expect(find.text('?'), findsOneWidget); + }); + + testWidgets('defaults to the shared hash color, not AppColors.primary', ( + tester, + ) async { + // 'commenter.test' deliberately hashes to a non-coral slot so this + // distinguishes the unified color from the old AppColors.primary. + const name = 'commenter.test'; + expect( + DisplayUtils.getFallbackColor(name).toARGB32(), + isNot(AppColors.primary.toARGB32()), + reason: 'test fixture must hash away from the legacy color', + ); + + await tester.pumpWidget(wrap(const UserAvatar(name: name, size: 24))); + + expect( + decorationBehind(tester, find.text('C')).color!.toARGB32(), + DisplayUtils.getFallbackColor(name).toARGB32(), + ); + }); + + testWidgets('fallbackColor overrides the hash color', (tester) async { + // Bluesky cards keep their own themed fallback through this param. + await tester.pumpWidget( + wrap( + const UserAvatar( + name: 'ada.test', + size: 40, + fallbackColor: Color(0xFF123456), + ), + ), + ); + + expect( + decorationBehind(tester, find.text('A')).color!.toARGB32(), + 0xFF123456, + ); + }); + + testWidgets('fallback text is white by default and overridable', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const UserAvatar(name: 'ada.test', size: 40)), + ); + expect( + tester.widget(find.text('A')).style?.color?.toARGB32(), + Colors.white.toARGB32(), + ); + + await tester.pumpWidget( + wrap( + const UserAvatar( + name: 'ada.test', + size: 40, + fallbackTextColor: AppColors.coral, + ), + ), + ); + expect( + tester.widget(find.text('A')).style?.color?.toARGB32(), + AppColors.coral.toARGB32(), + ); + }); + + testWidgets('fallbackIcon replaces the initial', (tester) async { + // profile_header / edit_profile_screen show Icons.person, no letter. + await tester.pumpWidget( + wrap( + const UserAvatar( + name: 'ada.test', + size: 74, + fallbackIcon: Icon(Icons.person, size: 40), + ), + ), + ); + + expect(find.byIcon(Icons.person), findsOneWidget); + expect(find.text('A'), findsNothing); + }); + + testWidgets('fills exactly size x size', (tester) async { + await tester.pumpWidget( + wrap(const UserAvatar(name: 'ada.test', size: 24)), + ); + + expect( + tester.getSize( + find.ancestor( + of: find.text('A'), + matching: find.byType(DecoratedBox), + ).first, + ), + const Size(24, 24), + ); + }); + + testWidgets('is circular', (tester) async { + const size = 24.0; + await tester.pumpWidget( + wrap(const UserAvatar(name: 'ada.test', size: size)), + ); + + final decoration = decorationBehind(tester, find.text('A')); + final isCircle = + decoration.shape == BoxShape.circle || + decoration.borderRadius == BorderRadius.circular(size / 2); + expect(isCircle, isTrue, reason: 'user avatars are always circular'); + }); + }); + + group('UserAvatar image', () { + testWidgets('loads the avatar at exactly the avatar size', (tester) async { + // The comment_card bug this widget replaces was an image smaller than + // its own fallback, which reflowed the row when loading failed. + await tester.pumpWidget( + wrap( + const UserAvatar( + name: 'ada.test', + size: 24, + avatarUrl: 'https://example.com/a.jpg', + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.imageUrl, 'https://example.com/a.jpg'); + expect(image.width, 24); + expect(image.height, 24); + }); + + testWidgets('an empty avatarUrl falls back instead of loading', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const UserAvatar(name: 'ada.test', size: 24, avatarUrl: '')), + ); + + expect(find.byType(CachedNetworkImage), findsNothing); + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('clips with at most one ClipOval', (tester) async { + // ProfileHeader's geometry tests locate the avatar via a single + // ClipOval descendant (test/widgets/profile_header_test.dart) — nesting + // another one inside UserAvatar would break them. + await tester.pumpWidget( + wrap( + const UserAvatar( + name: 'ada.test', + size: 74, + avatarUrl: 'https://example.com/a.jpg', + ), + ), + ); + + expect( + tester.widgetList(find.byType(ClipOval)).length, + lessThanOrEqualTo(1), + ); + }); + }); +}