diff --git a/docs/IMPLEMENTATION_FEED.md b/docs/IMPLEMENTATION_FEED.md index 91517e0..f5518da 100644 --- a/docs/IMPLEMENTATION_FEED.md +++ b/docs/IMPLEMENTATION_FEED.md @@ -76,8 +76,7 @@ TimelineResponse // Top-level feed response with cursor │ ├─ AuthorView │ ├─ CommunityRef │ ├─ PostStats - │ ├─ PostEmbed (optional) - │ │ └─ ExternalEmbed (for link previews) + │ ├─ PostEmbed (optional, sealed union — see below) │ └─ PostFacet[] (optional) └─ FeedReason (optional) ``` @@ -85,25 +84,38 @@ TimelineResponse // Top-level feed response with cursor **Key features:** - All models use factory constructors for JSON deserialization - Handles null feed arrays (backend returns `{"feed": null}` for empty feeds) -- External embeds parse thumbnail URLs, titles, descriptions - Optional fields properly handled throughout +- Embed parsing is defensive: media URLs must be http(s), aspect ratios are + validated, and anything malformed degrades to `UnknownPostEmbed` rather + than throwing (federated records reach the client unvalidated) -**Example PostEmbed with ExternalEmbed:** +**PostEmbed sealed union** (`$type`-discriminated, exhaustive-switch safe): ```dart -class PostEmbed { - final String type; // e.g., "social.coves.embed.external" - final ExternalEmbed? external; // Parsed external link data - final Map data; // Raw embed data +sealed class PostEmbed { + final String type; // Raw $type string + final Map data; // Raw embed JSON } -class ExternalEmbed { - final String uri; // Link URL - final String? title; // Link title - final String? description; // Link description - final String? thumb; // Thumbnail image URL - final String? domain; // Domain name +final class ExternalPostEmbed extends PostEmbed { // link previews + final ExternalEmbed external; // uri, title?, description?, thumb?, … } +final class QuotePostEmbed extends PostEmbed { // quoted Bluesky post + final BlueskyPostEmbed post; +} +final class ImagesPostEmbed extends PostEmbed { // images#view, 1–8 images + final List images; // thumb + fullsize URLs, alt?, aspectRatio? +} +final class VideoPostEmbed extends PostEmbed { // video#view + final String video; // PDS video URL + final String? thumbnail; + final String? alt; + final int? duration; // seconds +} +final class UnknownPostEmbed extends PostEmbed {} // unrecognized/malformed ``` +Legacy call sites can still use the bridge getters `embed.external` / +`embed.blueskyPost` (null on other variants); new code should switch on the +sealed variants. --- diff --git a/integration_test/media_validation_test.dart b/integration_test/media_validation_test.dart new file mode 100644 index 0000000..8d11581 --- /dev/null +++ b/integration_test/media_validation_test.dart @@ -0,0 +1,213 @@ +// Live end-to-end validation of native media embeds against the real local +// backend (AppView on :8081, image proxy on :8080, PDS on :3001). +// +// Prerequisites (see docs in the Coves backend repo): +// - `make dev-up` + `make run` in ~/Code/coves +// - the media validation posts seeded in the news community (five posts: +// single image, 3-image gallery, title-less image, hostile pre-stamped +// #view embed, and a video whose blob is a jpeg so playback fails into +// the error state by design) +// +// Run on a booted simulator: +// flutter test integration_test/media_validation_test.dart -d +import 'package:coves_flutter/main.dart' as app; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Pumps until [finder] matches, failing after [timeout]. +/// +/// pumpAndSettle never settles on this app (feed shimmer/animations), so all +/// waiting is bounded polling instead. +Future waitFor( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), + String? reason, +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isNotEmpty) { + return; + } + } + fail('Timed out waiting for $finder${reason == null ? '' : ' — $reason'}'); +} + +Future settleABit(WidgetTester tester) async { + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 250)); + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('native media embeds render end-to-end against live backend', ( + tester, + ) async { + // Pre-accept the EULA and community guidelines gates so the app boots + // straight to the landing screen. + SharedPreferences.setMockInitialValues({ + 'eula_accepted_version': 1, + 'community_guidelines_accepted_version': 1, + }); + + // bootstrapCovesApp is main() minus SentryFlutter.init, whose + // FlutterError.onError override the test binding rejects. + await tester.pumpWidget(await app.bootstrapCovesApp()); + await settleABit(tester); + + // ── Landing → guest feed. + if (find.text('Explore communities').evaluate().isNotEmpty) { + await tester.tap(find.text('Explore communities')); + } + await waitFor( + tester, + find.byKey(const Key('post-images-embed')), + timeout: const Duration(seconds: 45), + reason: 'guest feed should load and show the seeded image posts', + ); + + // ── Feed: single-image post renders with its media block. + expect( + find.byKey(const Key('post-images-embed')), + findsWidgets, + reason: 'seeded image posts must render media blocks in the feed', + ); + + // Gallery post shows its 1/N count badge. + await waitFor(tester, find.byKey(const Key('post-images-count-badge'))); + expect(find.text('1/3'), findsWidgets); + + // ── Hostile post: title renders, and its card carries NO media block. + final hostileTitle = find.textContaining('Hostile embed'); + if (hostileTitle.evaluate().isEmpty) { + await tester.fling( + find.byType(Scrollable).first, + const Offset(0, -600), + 1500, + ); + await settleABit(tester); + } + expect( + hostileTitle, + findsWidgets, + reason: + 'the hostile-embed post must still render as a text post — ' + 'if the whole feed is missing, the never-throws contract broke', + ); + + // ── Video post: media block, duration badge, play overlay. + var videoBlock = find.byKey(const Key('post-video-embed')); + for (var i = 0; i < 6 && videoBlock.evaluate().isEmpty; i++) { + await tester.fling( + find.byType(Scrollable).first, + const Offset(0, -500), + 1500, + ); + await settleABit(tester); + videoBlock = find.byKey(const Key('post-video-embed')); + } + expect(videoBlock, findsOneWidget); + expect(find.byKey(const Key('post-video-play-overlay')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('post-video-duration-badge')), + matching: find.text('12:34'), + ), + findsOneWidget, + ); + + // ── Video tap → fullscreen player → jpeg-as-video fails into the H5 + // error state (visible message + close), never an infinite spinner. + await tester.ensureVisible(videoBlock); + await settleABit(tester); + await tester.tap(videoBlock, warnIfMissed: false); + await settleABit(tester); + expect( + find.byKey(const Key('fullscreen-video-error')).evaluate().isNotEmpty || + find.byType(CircularProgressIndicator).evaluate().isNotEmpty, + isTrue, + reason: + 'tapping the video must push the fullscreen player ' + '(error state or still-initializing spinner)', + ); + // AVFoundation can take a while to reject image bytes; the contract is + // that it must END in the error state, never an eternal spinner. + await waitFor( + tester, + find.byKey(const Key('fullscreen-video-error')), + timeout: const Duration(seconds: 60), + reason: 'a bad video must land in the error state, not a spinner', + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + // Dismiss the player. The close affordance's 'Close' semantics are + // asserted by the H5 unit tests; here we just pop the route. + tester.state(find.byType(Navigator).last).pop(); + await settleABit(tester); + expect(find.byKey(const Key('fullscreen-video-error')), findsNothing); + + // ── Feed → detail: tap the gallery post's media block. + var galleryBadge = find.byKey(const Key('post-images-count-badge')); + for (var i = 0; i < 6 && galleryBadge.evaluate().isEmpty; i++) { + await tester.fling( + find.byType(Scrollable).first, + const Offset(0, 500), + 1500, + ); + await settleABit(tester); + galleryBadge = find.byKey(const Key('post-images-count-badge')); + } + final galleryBlock = + find + .ancestor( + of: galleryBadge, + matching: find.byKey(const Key('post-images-embed')), + ) + .first; + await tester.ensureVisible(galleryBlock); + await settleABit(tester); + await tester.tap(galleryBlock, warnIfMissed: false); + await settleABit(tester); + + // ── Detail: native gallery with page indicator; swipe advances it. + await waitFor(tester, find.byKey(const Key('detail-images-embed'))); + final indicator = find.byKey(const Key('detail-images-page-indicator')); + expect(indicator, findsOneWidget); + expect( + find.descendant(of: indicator, matching: find.text('1/3')), + findsOneWidget, + ); + await tester.fling( + find.descendant( + of: find.byKey(const Key('detail-images-embed')), + matching: find.byType(PageView), + ), + const Offset(-350, 0), + 1200, + ); + await settleABit(tester); + expect( + find.descendant(of: indicator, matching: find.text('2/3')), + findsOneWidget, + reason: 'swiping the gallery must advance the page indicator', + ); + + // ── Tap the gallery → fullscreen zoomable viewer, then dismiss. + await tester.tap( + find.byKey(const Key('detail-images-embed')), + warnIfMissed: false, + ); + await waitFor(tester, find.byKey(const Key('detail-image-viewer'))); + expect(find.byType(InteractiveViewer), findsOneWidget); + final nav = tester.state(find.byType(Navigator).last); + expect(nav.canPop(), isTrue); + nav.pop(); + await settleABit(tester); + expect(find.byKey(const Key('detail-image-viewer')), findsNothing); + expect(find.byKey(const Key('detail-images-embed')), findsOneWidget); + }); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2a0dc3d..4160032 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,6 +9,8 @@ PODS: - TOCropViewController (~> 2.7.4) - image_picker_ios (0.0.1): - Flutter + - integration_test (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -40,6 +42,7 @@ DEPENDENCIES: - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) @@ -65,6 +68,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_cropper/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -88,6 +93,7 @@ SPEC CHECKSUMS: flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 image_cropper: 5f162dcf988100dc1513f9c6b7eb42cd6fbf9156 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 diff --git a/lib/constants/embed_types.dart b/lib/constants/embed_types.dart index 809c36b..f3e7345 100644 --- a/lib/constants/embed_types.dart +++ b/lib/constants/embed_types.dart @@ -16,4 +16,16 @@ class EmbedTypes { /// Embedded Bluesky post as served by the appview (with resolved data). static const postView = 'social.coves.embed.post#view'; + + /// Image gallery record (unhydrated: carries atproto blob refs). + static const images = 'social.coves.embed.images'; + + /// Image gallery as served by the appview (blobs resolved to URLs). + static const imagesView = 'social.coves.embed.images#view'; + + /// Video record (unhydrated: carries atproto blob refs). + static const video = 'social.coves.embed.video'; + + /// Video as served by the appview (blobs resolved to URLs). + static const videoView = 'social.coves.embed.video#view'; } diff --git a/lib/main.dart b/lib/main.dart index 330e96f..c244f18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,161 +60,172 @@ Future main() async { ), ); - // Initialize auth provider - final authProvider = AuthProvider(); - try { - await authProvider.initialize(); - } on Exception catch (error, stackTrace) { - // Log initialization failure but continue - user can retry login - await Sentry.captureException( - error, - stackTrace: stackTrace, - withScope: (scope) { - scope.setTag('phase', 'auth_initialization'); - }, - ); - } + runApp(await bootstrapCovesApp()); + }, + ); +} - // Initialize EULA acceptance provider - // Note: initialize() handles errors internally (fail-closed design) - final eulaProvider = EulaProvider(); - try { - await eulaProvider.initialize(); - } on Exception catch (error, stackTrace) { - await Sentry.captureException( - error, - stackTrace: stackTrace, - withScope: (scope) { - scope.setTag('phase', 'eula_initialization'); - }, - ); - } +/// Initializes providers and services and returns the root app widget. +/// +/// Extracted from [main] so integration tests can pump the real app without +/// [SentryFlutter.init], whose FlutterError.onError override the test +/// binding rejects. Mirrors the [createRouter] @visibleForTesting pattern. +@visibleForTesting +Future bootstrapCovesApp() async { + // Initialize auth provider + final authProvider = AuthProvider(); + try { + await authProvider.initialize(); + } on Exception catch (error, stackTrace) { + // Log initialization failure but continue - user can retry login + await Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) { + scope.setTag('phase', 'auth_initialization'); + }, + ); + } - // Initialize community guidelines acceptance provider - // Note: initialize() handles errors internally (fail-closed design) - final communityGuidelinesProvider = CommunityGuidelinesProvider(); - try { - await communityGuidelinesProvider.initialize(); - } on Exception catch (error, stackTrace) { - await Sentry.captureException( - error, - stackTrace: stackTrace, - withScope: (scope) { - scope.setTag('phase', 'community_guidelines_initialization'); - }, - ); - } + // Initialize EULA acceptance provider + // Note: initialize() handles errors internally (fail-closed design) + final eulaProvider = EulaProvider(); + try { + await eulaProvider.initialize(); + } on Exception catch (error, stackTrace) { + await Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) { + scope.setTag('phase', 'eula_initialization'); + }, + ); + } - // Initialize vote service with auth callbacks - // Votes go through the Coves backend (which proxies to PDS with DPoP) - // Includes token refresh and sign-out handlers for automatic 401 recovery - final voteService = VoteService( - sessionGetter: () async => authProvider.session, - didGetter: () => authProvider.did, - tokenRefresher: authProvider.refreshToken, - signOutHandler: authProvider.signOut, - ); + // Initialize community guidelines acceptance provider + // Note: initialize() handles errors internally (fail-closed design) + final communityGuidelinesProvider = CommunityGuidelinesProvider(); + try { + await communityGuidelinesProvider.initialize(); + } on Exception catch (error, stackTrace) { + await Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) { + scope.setTag('phase', 'community_guidelines_initialization'); + }, + ); + } - // Initialize comment service with auth callbacks - // Comments go through the Coves backend (which proxies to PDS with DPoP) - final commentService = CommentService( - sessionGetter: () async => authProvider.session, - tokenRefresher: authProvider.refreshToken, - signOutHandler: authProvider.signOut, - ); + // Initialize vote service with auth callbacks + // Votes go through the Coves backend (which proxies to PDS with DPoP) + // Includes token refresh and sign-out handlers for automatic 401 recovery + final voteService = VoteService( + sessionGetter: () async => authProvider.session, + didGetter: () => authProvider.did, + tokenRefresher: authProvider.refreshToken, + signOutHandler: authProvider.signOut, + ); - runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: authProvider), - ChangeNotifierProvider.value(value: eulaProvider), - ChangeNotifierProvider.value(value: communityGuidelinesProvider), - ChangeNotifierProvider( - create: - (_) => VoteProvider( - voteService: voteService, - authProvider: authProvider, - ), - ), - ChangeNotifierProvider( - create: (_) => CommunitySubscriptionProvider( - authProvider: authProvider, - ), + // Initialize comment service with auth callbacks + // Comments go through the Coves backend (which proxies to PDS with DPoP) + final commentService = CommentService( + sessionGetter: () async => authProvider.session, + tokenRefresher: authProvider.refreshToken, + signOutHandler: authProvider.signOut, + ); + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider.value(value: eulaProvider), + ChangeNotifierProvider.value(value: communityGuidelinesProvider), + ChangeNotifierProvider( + create: + (_) => VoteProvider( + voteService: voteService, + authProvider: authProvider, ), - ChangeNotifierProvider( - create: (_) => BlockProvider( - apiService: CovesApiService( - tokenGetter: () async => authProvider.session?.token, - tokenRefresher: authProvider.refreshToken, - signOutHandler: authProvider.signOut, - ), - authProvider: authProvider, + ), + ChangeNotifierProvider( + create: + (_) => CommunitySubscriptionProvider(authProvider: authProvider), + ), + ChangeNotifierProvider( + create: + (_) => BlockProvider( + apiService: CovesApiService( + tokenGetter: () async => authProvider.session?.token, + tokenRefresher: authProvider.refreshToken, + signOutHandler: authProvider.signOut, ), + authProvider: authProvider, ), - ChangeNotifierProxyProvider3< - AuthProvider, - VoteProvider, - CommunitySubscriptionProvider, - MultiFeedProvider - >( - create: - (context) => MultiFeedProvider( - authProvider, - voteProvider: context.read(), - subscriptionProvider: - context.read(), - ), - update: (context, auth, vote, subscription, previous) { - // Reuse existing provider to maintain state across rebuilds - return previous ?? - MultiFeedProvider( - auth, - voteProvider: vote, - subscriptionProvider: subscription, - ); - }, + ), + ChangeNotifierProxyProvider3< + AuthProvider, + VoteProvider, + CommunitySubscriptionProvider, + MultiFeedProvider + >( + create: + (context) => MultiFeedProvider( + authProvider, + voteProvider: context.read(), + subscriptionProvider: + context.read(), ), - // CommentsProviderCache manages per-post CommentsProvider instances - // with LRU eviction and sign-out cleanup - ProxyProvider2( - create: - (context) => CommentsProviderCache( - authProvider: authProvider, - voteProvider: context.read(), - commentService: commentService, - ), - update: (context, auth, vote, previous) { - // Reuse existing cache - return previous ?? - CommentsProviderCache( - authProvider: auth, - voteProvider: vote, - commentService: commentService, - ); - }, - dispose: (_, cache) => cache.dispose(), + update: (context, auth, vote, subscription, previous) { + // Reuse existing provider to maintain state across rebuilds + return previous ?? + MultiFeedProvider( + auth, + voteProvider: vote, + subscriptionProvider: subscription, + ); + }, + ), + // CommentsProviderCache manages per-post CommentsProvider instances + // with LRU eviction and sign-out cleanup + ProxyProvider2( + create: + (context) => CommentsProviderCache( + authProvider: authProvider, + voteProvider: context.read(), + commentService: commentService, ), - // StreamableService for video embeds - Provider(create: (_) => StreamableService()), - // UserProfileProvider for profile pages - ChangeNotifierProxyProvider2( - create: (context) => UserProfileProvider( - authProvider, - voteProvider: context.read(), - ), - update: (context, auth, vote, previous) { - // Propagate auth changes to existing provider - previous?.updateAuthProvider(auth); - return previous ?? - UserProfileProvider(auth, voteProvider: vote); - }, + update: (context, auth, vote, previous) { + // Reuse existing cache + return previous ?? + CommentsProviderCache( + authProvider: auth, + voteProvider: vote, + commentService: commentService, + ); + }, + dispose: (_, cache) => cache.dispose(), + ), + // StreamableService for video embeds + Provider(create: (_) => StreamableService()), + // UserProfileProvider for profile pages + ChangeNotifierProxyProvider2< + AuthProvider, + VoteProvider, + UserProfileProvider + >( + create: + (context) => UserProfileProvider( + authProvider, + voteProvider: context.read(), ), - ], - child: const CovesApp(), - ), - ); - }, + update: (context, auth, vote, previous) { + // Propagate auth changes to existing provider + previous?.updateAuthProvider(auth); + return previous ?? UserProfileProvider(auth, voteProvider: vote); + }, + ), + ], + child: const CovesApp(), ); } @@ -225,8 +236,10 @@ class CovesApp extends StatelessWidget { Widget build(BuildContext context) { final authProvider = Provider.of(context, listen: false); final eulaProvider = Provider.of(context, listen: false); - final guidelinesProvider = - Provider.of(context, listen: false); + final guidelinesProvider = Provider.of( + context, + listen: false, + ); return MaterialApp.router( title: 'Coves', diff --git a/lib/models/post.dart b/lib/models/post.dart index 6e00fc4..3587d71 100644 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -16,18 +16,23 @@ class TimelineResponse { TimelineResponse({required this.feed, this.cursor}); factory TimelineResponse.fromJson(Map json) { - // Handle null feed array from backend + // Handle a null or non-list feed array from the backend final feedData = json['feed']; final feedList = []; - if (feedData != null) { - // Parse feed items, skipping any that fail to parse - for (final item in feedData as List) { + if (feedData is List) { + // Parse feed items, skipping any that fail to parse. One bad post + // must never cost the reader the whole feed, so the guard is broad: + // items are type-checked rather than cast, and the catch is + // `on Object` because an unchecked cast deep in a federated record + // raises a TypeError, which is an Error and would otherwise escape. + for (final item in feedData) { + if (item is! Map) { + continue; + } try { - feedList.add( - FeedViewPost.fromJson(item as Map), - ); - } on Exception catch (e) { + feedList.add(FeedViewPost.fromJson(item)); + } on Object catch (e) { // Skip malformed posts (e.g., deleted posts with missing data) if (kDebugMode) { debugPrint('⚠️ Skipping malformed feed item: $e'); @@ -36,7 +41,14 @@ class TimelineResponse { } } - return TimelineResponse(feed: feedList, cursor: json['cursor'] as String?); + // The cursor comes from the AppView's response envelope rather than a + // federated record, but a wrong-typed value must degrade like every + // other field here, not abort the whole feed parse. + final cursor = json['cursor']; + return TimelineResponse( + feed: feedList, + cursor: cursor is String ? cursor : null, + ); } final List feed; final String? cursor; @@ -99,11 +111,7 @@ class ViewerState { /// When a post is deleted, this record will be null and PostView.isDeleted /// will be true. class PostRecord { - const PostRecord({ - this.title, - this.content, - this.facets, - }); + const PostRecord({this.title, this.content, this.facets}); factory PostRecord.fromJson(Map json) { return PostRecord( @@ -255,11 +263,12 @@ class CommunityRef { name: json['name'] as String, handle: json['handle'] as String?, avatar: json['avatar'] as String?, - viewer: json['viewer'] != null - ? CommunityRefViewerState.fromJson( - json['viewer'] as Map, - ) - : null, + viewer: + json['viewer'] != null + ? CommunityRefViewerState.fromJson( + json['viewer'] as Map, + ) + : null, ); } final String did; @@ -276,9 +285,7 @@ class CommunityRefViewerState { CommunityRefViewerState({this.subscribed}); factory CommunityRefViewerState.fromJson(Map json) { - return CommunityRefViewerState( - subscribed: json['subscribed'] as bool?, - ); + return CommunityRefViewerState(subscribed: json['subscribed'] as bool?); } /// Whether the current user is subscribed to this community @@ -307,57 +314,362 @@ class PostStats { final int commentCount; } -class PostEmbed { - PostEmbed({ - required this.type, - this.external, - this.blueskyPost, - required this.data, - }); - +/// An embed attached to a post, discriminated by its lexicon `$type`. +/// +/// The appview serves embeds as a `$type`-tagged union. Hydration is +/// all-or-nothing: when it fails, the *record* shape (no `#view` suffix) is +/// served with atproto blob refs instead of URLs. Those are unrenderable, so +/// they parse to [UnknownPostEmbed] — the client must never build blob URLs +/// itself, media has to flow through the appview's proxied URLs. +sealed class PostEmbed { + const PostEmbed({required this.type, required this.data}); + + /// Parses any embed json. Never throws on map input: anything unrecognized + /// or malformed degrades to [UnknownPostEmbed]. factory PostEmbed.fromJson(Map json) { - final embedType = json[r'$type'] as String? ?? 'unknown'; - ExternalEmbed? externalEmbed; - BlueskyPostEmbed? blueskyPostEmbed; - - if ((embedType == EmbedTypes.external || - embedType == EmbedTypes.externalView) && - json['external'] != null) { - externalEmbed = ExternalEmbed.fromJson( - json['external'] as Map, + final rawType = json[r'$type']; + final embedType = rawType is String ? rawType : 'unknown'; + + if (embedType == EmbedTypes.imagesView) { + final images = _parseEmbedImages(json['images']); + if (images == null) { + return UnknownPostEmbed(type: embedType, data: json); + } + return ImagesPostEmbed(type: embedType, images: images, data: json); + } + + if (embedType == EmbedTypes.videoView) { + final video = json['video']; + final thumbnail = json['thumbnail']; + if (video is! String || + !_isRenderableMediaUrl(video) || + thumbnail is! String? || + (thumbnail != null && !_isRenderableMediaUrl(thumbnail))) { + return UnknownPostEmbed(type: embedType, data: json); + } + final duration = json['duration']; + return VideoPostEmbed( + type: embedType, + video: video, + thumbnail: thumbnail, + alt: _parseAltText(json['alt']), + duration: duration is int ? duration : null, + data: json, ); } + // Unhydrated record shapes carry blob refs, never renderable URLs. + if (embedType == EmbedTypes.images || embedType == EmbedTypes.video) { + return UnknownPostEmbed(type: embedType, data: json); + } + if (embedType == EmbedTypes.post || embedType == EmbedTypes.postView) { - blueskyPostEmbed = BlueskyPostEmbed.fromJson(json); + try { + return QuotePostEmbed( + type: embedType, + post: BlueskyPostEmbed.fromJson(json), + data: json, + ); + // Broader than FormatException: BlueskyPostEmbed casts `resolved` + // to a Map, so a hostile record raises a TypeError instead. + } on Object catch (e) { + if (kDebugMode) { + debugPrint('PostEmbed: unparseable $embedType embed: $e'); + } + return UnknownPostEmbed(type: embedType, data: json); + } + } + + if (embedType == EmbedTypes.external || + embedType == EmbedTypes.externalView) { + final external = json['external']; + if (external is Map) { + // ExternalEmbed.fromJson casts its fields without checking, and + // EmbedSource.fromJson throws outright on a bad source url. Wrap + // the whole branch so any of that — including future drift — + // degrades to an unrenderable embed instead of escaping. + try { + return ExternalPostEmbed( + type: embedType, + external: ExternalEmbed.fromJson(external), + data: json, + ); + } on Object { + if (kDebugMode) { + debugPrint('PostEmbed: unparseable $embedType external payload'); + } + return UnknownPostEmbed(type: embedType, data: json); + } + } } // Fallback: if no typed embed was parsed but we have a uri field at the // top level, treat it as an external link embed. This handles cases where // the backend returns simple link embeds without the full $type wrapper. - if (externalEmbed == null && - blueskyPostEmbed == null && - json['uri'] != null) { + // Media embeds are excluded above so a malformed images/video embed that + // happens to carry a uri stays unknown rather than rendering as a link. + if (json['uri'] is String) { if (kDebugMode) { debugPrint( 'PostEmbed fallback: treating unrecognized embed as external link. ' 'Type was: ${json[r'$type']}, keys: ${json.keys.toList()}', ); } - externalEmbed = ExternalEmbed.fromJson(json); + // Guarded for the same reason as the typed branch above: the fallback + // hands the whole embed to the same unchecked casts. + try { + return ExternalPostEmbed( + type: embedType, + external: ExternalEmbed.fromJson(json), + data: json, + ); + } on Object { + if (kDebugMode) { + debugPrint('PostEmbed fallback: payload was not a usable link'); + } + return UnknownPostEmbed(type: embedType, data: json); + } } - return PostEmbed( - type: embedType, - external: externalEmbed, - blueskyPost: blueskyPostEmbed, - data: json, - ); + return UnknownPostEmbed(type: embedType, data: json); } + + /// The raw `$type`, or `'unknown'` when the embed carried no usable one. final String type; - final ExternalEmbed? external; - final BlueskyPostEmbed? blueskyPost; + + /// The raw embed json, preserved so unrecognized shapes are not lost. final Map data; + + /// The link payload — non-null only on [ExternalPostEmbed]. + ExternalEmbed? get external => null; + + /// The quoted Bluesky post — non-null only on [QuotePostEmbed]. + BlueskyPostEmbed? get blueskyPost => null; +} + +/// An external link card. +final class ExternalPostEmbed extends PostEmbed { + const ExternalPostEmbed({ + required super.type, + required this.external, + required super.data, + }); + + @override + final ExternalEmbed external; +} + +/// A quoted Bluesky post. +final class QuotePostEmbed extends PostEmbed { + const QuotePostEmbed({ + required super.type, + required this.post, + required super.data, + }); + + /// Reference to the quoted post, with resolved data when the appview + /// managed to fetch it. + final BlueskyPostEmbed post; + + @override + BlueskyPostEmbed get blueskyPost => post; +} + +/// A hydrated image gallery. [images] is guaranteed non-empty and every entry +/// has renderable urls — a partially hydrated gallery parses as unknown. +final class ImagesPostEmbed extends PostEmbed { + /// Throws [ArgumentError] when [images] is empty. Checked rather than + /// asserted because asserts are stripped in release, and an empty gallery + /// would reach the widgets as a `first` on an empty list. + ImagesPostEmbed({ + required super.type, + required List images, + required super.data, + }) : images = List.unmodifiable(images) { + if (images.isEmpty) { + throw ArgumentError.value( + images, + 'images', + 'ImagesPostEmbed requires at least one image', + ); + } + } + + final List images; +} + +/// A hydrated video with a playable url. +final class VideoPostEmbed extends PostEmbed { + const VideoPostEmbed({ + required super.type, + required this.video, + required super.data, + this.thumbnail, + this.alt, + this.duration, + }); + + /// Playable video url served by the appview. + final String video; + + /// Poster image url, when the appview provided one. + final String? thumbnail; + + /// Alt text supplied by the author. + final String? alt; + + /// Video length in seconds, when known. + final int? duration; +} + +/// An embed this client cannot render: an unknown `$type`, an unhydrated +/// record shape, or a malformed payload. Renders as no media. +final class UnknownPostEmbed extends PostEmbed { + const UnknownPostEmbed({required super.type, required super.data}); +} + +/// A single image from a hydrated gallery embed. +class EmbedImage { + const EmbedImage({ + required this.thumb, + required this.fullsize, + this.alt, + this.aspectRatio, + }); + + /// Feed-sized rendering url (800w). + final String thumb; + + /// Lightbox-sized rendering url (1600w). + final String fullsize; + + /// Alt text supplied by the author. + final String? alt; + + /// Intrinsic dimensions, used to reserve layout space before load. + final EmbedAspectRatio? aspectRatio; +} + +/// Intrinsic dimensions of an embedded image. +class EmbedAspectRatio { + /// Throws [ArgumentError] on a dimension below 1. Checked rather than + /// asserted: asserts are stripped in release, and a zero height would + /// reach the layout as a division by zero. + EmbedAspectRatio({required this.width, required this.height}) { + if (width < 1) { + throw ArgumentError.value(width, 'width', 'must be at least 1'); + } + if (height < 1) { + throw ArgumentError.value(height, 'height', 'must be at least 1'); + } + } + + final int width; + final int height; +} + +/// Whether a hydrated media url is safe to hand to the image/video stack. +/// +/// 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; +} + +/// Lexicon caps for a hydrated gallery: at most 8 images, alt text at most +/// 10000 characters. +const int _maxGalleryImages = 8; +const int _maxAltLength = 10000; + +/// Reads alt text, truncated to the lexicon's limit. +/// +/// Lenient about type — alt is decorative enough that a malformed one is +/// dropped rather than poisoning the embed — but not about length: nothing +/// downstream bounds it before it reaches the semantics tree. +String? _parseAltText(Object? raw) { + if (raw is! String) { + return null; + } + return raw.length > _maxAltLength ? raw.substring(0, _maxAltLength) : raw; +} + +/// Parses the `images` list of a hydrated gallery embed. +/// +/// Returns null when the gallery must not be rendered at all: a missing, +/// non-list or empty value, or any entry lacking a String `thumb` and +/// `fullsize` (e.g. one still carrying a blob ref) or carrying one that is +/// not an http(s) url. This mirrors the appview's all-or-nothing hydration — +/// half a gallery is never shown, and one hostile url poisons the rest. +List? _parseEmbedImages(Object? raw) { + // The lexicon caps a gallery at 8 and the appview never serves more, so a + // longer list is a malformed or hostile record rather than something to + // render partially. Checked before the loop so an absurd list costs a + // length read, not a walk. + if (raw is! List || raw.isEmpty || raw.length > _maxGalleryImages) { + return null; + } + + final images = []; + for (final entry in raw) { + if (entry is! Map) { + return null; + } + + final thumb = entry['thumb']; + final fullsize = entry['fullsize']; + if (thumb is! String || + fullsize is! String || + !_isRenderableMediaUrl(thumb) || + !_isRenderableMediaUrl(fullsize)) { + return null; + } + + images.add( + EmbedImage( + thumb: thumb, + fullsize: fullsize, + alt: _parseAltText(entry['alt']), + aspectRatio: _parseEmbedAspectRatio(entry['aspectRatio']), + ), + ); + } + + return images; +} + +/// Parses an image `aspectRatio`, or null when it is absent or malformed. +/// +/// A bad ratio only costs layout hinting, so it is dropped without +/// rejecting the image itself. +EmbedAspectRatio? _parseEmbedAspectRatio(Object? raw) { + if (raw is! Map) { + return null; + } + + final width = raw['width']; + final height = raw['height']; + if (width is! int || height is! int || width < 1 || height < 1) { + return null; + } + + return EmbedAspectRatio(width: width, height: height); } class ExternalEmbed { @@ -422,11 +734,7 @@ class ExternalEmbed { /// A source link aggregated into a megathread class EmbedSource { - EmbedSource({ - required this.uri, - this.title, - this.domain, - }); + EmbedSource({required this.uri, this.title, this.domain}); factory EmbedSource.fromJson(Map json) { final uri = json['uri']; diff --git a/lib/screens/home/create_post_screen.dart b/lib/screens/home/create_post_screen.dart index 72269d4..a183a2d 100644 --- a/lib/screens/home/create_post_screen.dart +++ b/lib/screens/home/create_post_screen.dart @@ -310,7 +310,7 @@ class _CreatePostScreenState extends State PostEmbed? embed; final url = _urlController.text.trim(); if (url.isNotEmpty) { - embed = PostEmbed( + embed = ExternalPostEmbed( type: EmbedTypes.external, external: ExternalEmbed( uri: url, diff --git a/lib/screens/home/post_detail_screen.dart b/lib/screens/home/post_detail_screen.dart index fb2ea1a..c809f4d 100644 --- a/lib/screens/home/post_detail_screen.dart +++ b/lib/screens/home/post_detail_screen.dart @@ -84,6 +84,11 @@ class _PostDetailScreenState extends State { late CommentsProvider _commentsProvider; CommentsProviderCache? _commentsCache; + // Stored for dispose: a context.read there throws a FlutterError (an + // Error, which the old `on Exception` guard missed) when the tree is + // being finalized and the provider ancestor is already deactivated. + AuthProvider? _authProvider; + // Track initialization state bool _isInitialized = false; @@ -110,8 +115,8 @@ class _PostDetailScreenState extends State { /// Listen for auth state changes to handle sign-out void _setupAuthListener() { - final authProvider = context.read(); - authProvider.addListener(_onAuthChanged); + _authProvider = context.read(); + _authProvider!.addListener(_onAuthChanged); } /// Handle auth state changes (specifically sign-out) @@ -212,15 +217,11 @@ class _PostDetailScreenState extends State { @override void dispose() { - // Remove auth listener - try { - context.read().removeListener(_onAuthChanged); - } on Exception catch (e) { - // Context may not be valid during dispose - expected behavior - if (kDebugMode) { - debugPrint('dispose: auth listener removal failed: $e'); - } - } + // Remove auth listener via the stored reference — context.read here is + // unsafe once the ancestor is deactivated (tree finalization) and throws + // a FlutterError that `on Exception` cannot catch. + _authProvider?.removeListener(_onAuthChanged); + _authProvider = null; // Release provider pin in cache (prevents LRU eviction disposing an active // provider while this screen is in the navigation stack). @@ -418,10 +419,7 @@ class _PostDetailScreenState extends State { return Container( width: size, height: size, - decoration: BoxDecoration( - color: bgColor, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle), child: Center( child: Text( firstLetter, @@ -662,11 +660,17 @@ class _PostDetailScreenState extends State { } /// Handle comment submission (reply to post) - Future _handleCommentSubmit(String content, List facets) async { + Future _handleCommentSubmit( + String content, + List facets, + ) async { final messenger = ScaffoldMessenger.of(context); try { - await _commentsProvider.createComment(content: content, contentFacets: facets); + await _commentsProvider.createComment( + content: content, + contentFacets: facets, + ); if (mounted) { messenger.showSnackBar( @@ -749,7 +753,9 @@ class _PostDetailScreenState extends State { builder: (context) => ReplyScreen( comment: comment, - onSubmit: (content, facets) => _handleCommentReply(content, facets, comment), + onSubmit: + (content, facets) => + _handleCommentReply(content, facets, comment), commentsProvider: _commentsProvider, ), ), @@ -846,42 +852,46 @@ class _PostDetailScreenState extends State { tooltip: 'More options', color: AppColors.backgroundSecondary, onSelected: _handleMenuAction, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'copy_link', - child: Row( - children: [ - Icon(Icons.link, size: 20), - SizedBox(width: 12), - Text('Copy link'), - ], - ), - ), - // Report is hidden on the viewer's own posts - // (parity with the feed card menu). - if (context.read().did != - widget.post.post.author.did) - const PopupMenuItem( - value: 'report', - child: Row( - children: [ - Icon(Icons.flag_outlined, size: 20), - SizedBox(width: 12), - Text('Report'), - ], + itemBuilder: + (context) => [ + const PopupMenuItem( + value: 'copy_link', + child: Row( + children: [ + Icon(Icons.link, size: 20), + SizedBox(width: 12), + Text('Copy link'), + ], + ), ), - ), - const PopupMenuItem( - value: 'hide', - child: Row( - children: [ - Icon(Icons.visibility_off_outlined, size: 20), - SizedBox(width: 12), - Text('Hide post'), - ], - ), - ), - ], + // Report is hidden on the viewer's own posts + // (parity with the feed card menu). + if (context.read().did != + widget.post.post.author.did) + const PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.flag_outlined, size: 20), + SizedBox(width: 12), + Text('Report'), + ], + ), + ), + const PopupMenuItem( + value: 'hide', + child: Row( + children: [ + Icon( + Icons.visibility_off_outlined, + size: 20, + ), + SizedBox(width: 12), + Text('Hide post'), + ], + ), + ), + ], ), ], ), @@ -928,11 +938,12 @@ class _PostDetailScreenState extends State { key: _commentsHeaderKey, commentCount: PostDetailScreen.displayedCommentCount( - serverCount: widget - .post - .post - .stats - .commentCount, + serverCount: + widget + .post + .post + .stats + .commentCount, isLoading: isLoading, hasError: error != null, hasComments: comments.isNotEmpty, @@ -980,13 +991,16 @@ class _PostDetailScreenState extends State { onCommentTap: _openReplyToComment, collapsedComments: commentsProvider.collapsedComments, - onCollapseToggle: commentsProvider.toggleCollapsed, + onCollapseToggle: + commentsProvider.toggleCollapsed, onContinueThread: _onContinueThread, onLoadMoreReplies: _onLoadMoreReplies, loadingMoreReplies: commentsProvider.loadingMoreReplies, - onDelete: (uri) => - commentsProvider.deleteComment(commentUri: uri), + onDelete: + (uri) => commentsProvider.deleteComment( + commentUri: uri, + ), ), ); }, diff --git a/lib/widgets/detailed_post_view.dart b/lib/widgets/detailed_post_view.dart index 12f2c80..ff216ca 100644 --- a/lib/widgets/detailed_post_view.dart +++ b/lib/widgets/detailed_post_view.dart @@ -11,11 +11,35 @@ import '../utils/url_launcher.dart'; import 'bluesky_post_card.dart'; import 'external_link_bar.dart'; import 'fullscreen_video_player.dart'; +import 'post_card.dart' show formatVideoDuration; import 'rich_text_renderer.dart'; import 'source_link_bar.dart'; import 'tappable_author.dart'; -/// Social media style post detail view inspired by Reddit's clean, content-first design. +/// 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); +} + +/// Social media style post detail view inspired by Reddit's clean, +/// content-first design. /// /// Features: /// - Compact author row with avatar, handle, and timestamp @@ -44,14 +68,48 @@ class _DetailedPostViewState extends State { int _currentImageIndex = 0; final PageController _imagePageController = PageController(); + @override + void didUpdateWidget(DetailedPostView oldWidget) { + 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 + // 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) { + _currentImageIndex = 0; + if (_imagePageController.hasClients) { + _imagePageController.jumpToPage(0); + } + } + } + @override void dispose() { _imagePageController.dispose(); super.dispose(); } + /// Whether the post carries a native (images/video) embed to render. + bool get _hasNativeMedia { + final embed = widget.post.post.embed; + return embed is ImagesPostEmbed || embed is VideoPostEmbed; + } + /// Determines the content type for layout decisions _ContentType get _contentType { + // Native embeds are self-describing, so they short-circuit the + // heuristics below, which exist only to classify external link cards. + final nativeEmbed = widget.post.post.embed; + if (nativeEmbed is ImagesPostEmbed) { + return nativeEmbed.images.length > 1 + ? _ContentType.nativeGallery + : _ContentType.nativeSingleImage; + } + if (nativeEmbed is VideoPostEmbed) { + return _ContentType.nativeVideo; + } + final embed = widget.post.post.embed?.external; if (embed == null) { return _ContentType.textOnly; @@ -89,7 +147,8 @@ class _DetailedPostViewState extends State { // Media section - full width, content-first if (widget.post.post.embed?.external != null || - widget.post.post.embed?.blueskyPost != null) ...[ + widget.post.post.embed?.blueskyPost != null || + _hasNativeMedia) ...[ const SizedBox(height: 12), _buildMediaSection(), ], @@ -189,8 +248,8 @@ class _DetailedPostViewState extends State { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (context, url) => _buildAvatarPlaceholder(author, size), - errorWidget: (context, url, error) => - _buildAvatarPlaceholder(author, size), + errorWidget: + (context, url, error) => _buildAvatarPlaceholder(author, size), ), ); } @@ -200,9 +259,10 @@ class _DetailedPostViewState extends State { /// Placeholder avatar with initial Widget _buildAvatarPlaceholder(AuthorView author, double size) { - final initial = (author.displayName ?? author.handle).isNotEmpty - ? (author.displayName ?? author.handle)[0].toUpperCase() - : '?'; + final initial = + (author.displayName ?? author.handle).isNotEmpty + ? (author.displayName ?? author.handle)[0].toUpperCase() + : '?'; return Container( width: size, @@ -243,6 +303,14 @@ class _DetailedPostViewState extends State { /// Main media section based on content type Widget _buildMediaSection() { switch (_contentType) { + case _ContentType.nativeSingleImage: + return _buildNativeSingleImage(); + case _ContentType.nativeGallery: + return _buildNativeGallery(); + case _ContentType.nativeVideo: + return _NativeVideoEmbed( + embed: widget.post.post.embed! as VideoPostEmbed, + ); case _ContentType.video: return _buildVideoPlayer(); case _ContentType.multiImage: @@ -255,6 +323,130 @@ class _DetailedPostViewState extends State { } } + /// Native single image: full card width at its own aspect ratio, tapping + /// 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: () => _openImageViewer(image), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: _nativeAspectRatio(image.aspectRatio), + child: _buildNativeImage(image), + ), + ), + ), + ), + ); + } + + /// Native gallery: a swipeable carousel of fullsize images with an i/N + /// 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: () => _openImageViewer(images[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}', + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// 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; + } + + /// Opens the pinch-zoom viewer for a native image. + /// + /// Pushed synchronously, and through the navigator rather than the URL + /// launcher: native media stays inside the app. + void _openImageViewer(EmbedImage image) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _ImageViewerPage(image: image), + fullscreenDialog: true, + ), + ); + } + /// Video player with play button overlay Widget _buildVideoPlayer() { final embed = widget.post.post.embed!.external!; @@ -270,16 +462,16 @@ class _DetailedPostViewState extends State { final embed = widget.post.post.embed!.external!; final images = embed.images ?? []; - if (images.isEmpty) return const SizedBox.shrink(); + if (images.isEmpty) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.border.withValues(alpha: 0.5), - ), + border: Border.all(color: AppColors.border.withValues(alpha: 0.5)), ), child: Column( children: [ @@ -290,10 +482,11 @@ class _DetailedPostViewState extends State { topRight: Radius.circular(7), ), child: GestureDetector( - onTap: () => UrlLauncher.launchExternalUrl( - embed.uri, - context: context, - ), + onTap: + () => UrlLauncher.launchExternalUrl( + embed.uri, + context: context, + ), child: SizedBox( height: 300, child: PageView.builder( @@ -304,11 +497,14 @@ class _DetailedPostViewState extends State { itemCount: images.length, itemBuilder: (context, index) { final image = images[index]; - final imageUrl = image['thumb'] as String? ?? + final imageUrl = + image['thumb'] as String? ?? image['fullsize'] as String? ?? ''; - if (imageUrl.isEmpty) return _buildImagePlaceholder(); + if (imageUrl.isEmpty) { + return _buildImagePlaceholder(); + } return CachedNetworkImage( imageUrl: imageUrl, @@ -316,8 +512,8 @@ class _DetailedPostViewState extends State { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (context, url) => _buildImagePlaceholder(), - errorWidget: (context, url, error) => - _buildImagePlaceholder(), + errorWidget: + (context, url, error) => _buildImagePlaceholder(), ); }, ), @@ -327,15 +523,16 @@ class _DetailedPostViewState extends State { // Link bar with page indicator (bottom of card) GestureDetector( - onTap: () => UrlLauncher.launchExternalUrl( - embed.uri, - context: context, - ), + onTap: + () => UrlLauncher.launchExternalUrl( + embed.uri, + context: context, + ), child: Container( padding: const EdgeInsets.all(10), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.backgroundSecondary, - borderRadius: const BorderRadius.only( + borderRadius: BorderRadius.only( bottomLeft: Radius.circular(7), bottomRight: Radius.circular(7), ), @@ -399,21 +596,18 @@ class _DetailedPostViewState extends State { Widget _buildSingleImage() { final embed = widget.post.post.embed!.external!; - if (embed.thumb == null) return const SizedBox.shrink(); + if (embed.thumb == null) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: GestureDetector( - onTap: () => UrlLauncher.launchExternalUrl( - embed.uri, - context: context, - ), + onTap: () => UrlLauncher.launchExternalUrl(embed.uri, context: context), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.border.withValues(alpha: 0.5), - ), + border: Border.all(color: AppColors.border.withValues(alpha: 0.5)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -431,30 +625,32 @@ class _DetailedPostViewState extends State { fit: BoxFit.cover, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, - placeholder: (context, url) => Container( - height: 220, - color: AppColors.backgroundSecondary, - ), - errorWidget: (context, url, error) => Container( - height: 220, - color: AppColors.backgroundSecondary, - child: const Center( - child: Icon( - Icons.image_outlined, - color: AppColors.textMuted, - size: 40, + placeholder: + (context, url) => Container( + height: 220, + color: AppColors.backgroundSecondary, + ), + errorWidget: + (context, url, error) => Container( + height: 220, + color: AppColors.backgroundSecondary, + child: const Center( + child: Icon( + Icons.image_outlined, + color: AppColors.textMuted, + size: 40, + ), + ), ), - ), - ), ), ), // Link bar (bottom of card) Container( padding: const EdgeInsets.all(10), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.backgroundSecondary, - borderRadius: const BorderRadius.only( + borderRadius: BorderRadius.only( bottomLeft: Radius.circular(7), bottomRight: Radius.circular(7), ), @@ -515,9 +711,7 @@ class _DetailedPostViewState extends State { decoration: BoxDecoration( color: AppColors.backgroundSecondary.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.border.withValues(alpha: 0.5), - ), + border: Border.all(color: AppColors.border.withValues(alpha: 0.5)), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), @@ -530,7 +724,9 @@ class _DetailedPostViewState extends State { /// Sources section for megathreads Widget _buildSourcesSection() { final sources = widget.post.post.embed?.external?.sources; - if (sources == null || sources.isEmpty) return const SizedBox.shrink(); + if (sources == null || sources.isEmpty) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), @@ -567,11 +763,7 @@ class _DetailedPostViewState extends State { height: 280, color: AppColors.backgroundSecondary, child: const Center( - child: Icon( - Icons.image_outlined, - color: AppColors.textMuted, - size: 40, - ), + child: Icon(Icons.image_outlined, color: AppColors.textMuted, size: 40), ), ); } @@ -623,16 +815,18 @@ class _DetailedPostViewState extends State { 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), - ), + 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), + ), ), ); } @@ -656,7 +850,9 @@ class _VideoEmbedState extends State<_VideoEmbed> { widget.embed.provider?.toLowerCase() == 'streamable'; Future _playVideo() async { - if (!_isStreamable || widget.embed.thumb == null) return; + if (!_isStreamable || widget.embed.thumb == null) { + return; + } final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); @@ -664,10 +860,13 @@ class _VideoEmbedState extends State<_VideoEmbed> { setState(() => _isLoading = true); try { - final videoUrl = - await widget.streamableService.getVideoUrl(widget.embed.uri); + final videoUrl = await widget.streamableService.getVideoUrl( + widget.embed.uri, + ); - if (!mounted) return; + if (!mounted) { + return; + } if (videoUrl == null) { messenger.showSnackBar( @@ -697,7 +896,9 @@ class _VideoEmbedState extends State<_VideoEmbed> { @override Widget build(BuildContext context) { - if (widget.embed.thumb == null) return const SizedBox.shrink(); + if (widget.embed.thumb == null) { + return const SizedBox.shrink(); + } return Semantics( button: true, @@ -715,28 +916,28 @@ class _VideoEmbedState extends State<_VideoEmbed> { 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, + 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), - ), + child: Container(color: Colors.black.withValues(alpha: 0.3)), ), // Play button - simple and clean @@ -747,19 +948,20 @@ class _VideoEmbedState extends State<_VideoEmbed> { color: AppColors.textPrimary.withValues(alpha: 0.9), shape: BoxShape.circle, ), - child: _isLoading - ? const Padding( - padding: EdgeInsets.all(18), - child: CircularProgressIndicator( + 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, - strokeWidth: 2.5, + size: 36, ), - ) - : const Icon( - Icons.play_arrow_rounded, - color: AppColors.background, - size: 36, - ), ), ], ), @@ -768,8 +970,228 @@ class _VideoEmbedState extends State<_VideoEmbed> { } } +/// 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), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Fullscreen, pinch-zoomable view of one native image. +class _ImageViewerPage extends StatelessWidget { + const _ImageViewerPage({required this.image}); + + final EmbedImage image; + + @override + Widget build(BuildContext context) { + final alt = image.alt; + + Widget rendered = CachedNetworkImage( + imageUrl: image.fullsize, + fit: BoxFit.contain, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + // Show the thumb while the fullsize downloads. It is almost always + // already in the cache from the feed or the post body, so the viewer + // opens on the picture instead of on a black screen. + placeholder: + (context, url) => CachedNetworkImage( + imageUrl: image.thumb, + fit: BoxFit.contain, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorWidget: (context, url, error) => const SizedBox.shrink(), + ), + errorWidget: + (context, url, error) => const Icon( + Icons.broken_image, + color: AppColors.textMuted, + size: 48, + ), + ); + + if (alt != null && alt.isNotEmpty) { + rendered = Semantics(image: true, label: alt, child: rendered); + } + + return Scaffold( + key: const Key('detail-image-viewer'), + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: InteractiveViewer( + maxScale: 4, + child: Center(child: rendered), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.topRight, + child: IconButton( + icon: const Icon(Icons.close, color: AppColors.textPrimary), + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + ], + ), + ); + } +} + +/// 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 enum _ContentType { + nativeSingleImage, + nativeGallery, + nativeVideo, video, singleImage, multiImage, diff --git a/lib/widgets/fullscreen_video_player.dart b/lib/widgets/fullscreen_video_player.dart index d9dd89a..58f93db 100644 --- a/lib/widgets/fullscreen_video_player.dart +++ b/lib/widgets/fullscreen_video_player.dart @@ -25,6 +25,7 @@ class _FullscreenVideoPlayerState extends State bool _isDragging = false; VideoPlayerController? _videoController; bool _isInitializing = true; + bool _hasError = false; @override void initState() { @@ -63,13 +64,21 @@ class _FullscreenVideoPlayerState extends State _isInitializing = false; }); } - } on Exception catch (e) { + } on Object catch (_) { + // Deliberately catches Errors as well as Exceptions: the video_player + // platform channel throws UnimplementedError when no implementation is + // registered, and an `on Exception` clause lets that escape — leaving + // the user watching a spinner that never resolves. + // + // The thrown object is not logged: it can carry the video URL, which + // may be a signed blob endpoint with credentials in the query string. if (kDebugMode) { - debugPrint('Error initializing video: $e'); + debugPrint('FullscreenVideoPlayer: video initialization failed'); } if (mounted) { setState(() { _isInitializing = false; + _hasError = true; }); } } @@ -112,6 +121,69 @@ class _FullscreenVideoPlayerState extends State }); } + /// The centre of the screen: the failure notice, the loading spinner, or + /// the video itself. + Widget _buildStage() { + if (_hasError) { + return _buildErrorState(); + } + + if (_isInitializing || _videoController == null) { + return const Center( + child: CircularProgressIndicator(color: AppColors.loadingIndicator), + ); + } + + return Center( + child: AspectRatio( + aspectRatio: _videoController!.value.aspectRatio, + child: VideoPlayer(_videoController!), + ), + ); + } + + /// Shown when the video could not be initialized. + /// + /// The player is a full-screen route, so without a visible way out a + /// failure would otherwise trap the user on a black screen — swipe-to- + /// dismiss still works, but nothing on screen advertises it. + Widget _buildErrorState() { + return Center( + key: const Key('fullscreen-video-error'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.videocam_off_outlined, + color: AppColors.textSecondary, + size: 48, + ), + const SizedBox(height: 16), + Text( + "This video couldn't be played.", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.9), + fontSize: 15, + ), + ), + const SizedBox(height: 8), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon( + Icons.close, + color: AppColors.textPrimary, + semanticLabel: 'Close', + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { // Calculate opacity based on drag offset (fade out as user drags) @@ -137,21 +209,7 @@ class _FullscreenVideoPlayerState extends State _dragOffsetY, 0, ), - child: SizedBox.expand( - child: - _isInitializing || _videoController == null - ? const Center( - child: CircularProgressIndicator( - color: AppColors.loadingIndicator, - ), - ) - : Center( - child: AspectRatio( - aspectRatio: _videoController!.value.aspectRatio, - child: VideoPlayer(_videoController!), - ), - ), - ), + child: SizedBox.expand(child: _buildStage()), ), // Minimal controls at bottom (scrubber only) if (_videoController != null && diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index cffdd92..4d59574 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -18,11 +18,46 @@ import 'source_link_bar.dart'; import 'tappable_author.dart'; import 'tappable_community.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. +const double _widestMediaRatio = 16 / 9; +const double _tallestMediaRatio = 4 / 5; + +/// 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'; +} + /// Post card widget for displaying feed posts /// /// Displays a post with: /// - Community and author information /// - Post title and text content +/// - Native image and video embeds /// - External embed (link preview with image) /// - Action buttons (share, comment, like) /// @@ -76,6 +111,15 @@ class PostCard extends StatelessWidget { @override Widget build(BuildContext context) { + final media = _buildMediaEmbed(context); + // Everything that can sit under the title and therefore needs the gap + // after it: native media, either embed card, or the post text. + final hasContentBelowTitle = + media != null || + post.post.embed?.external != null || + post.post.embed?.blueskyPost != null || + post.post.text.isNotEmpty; + return Container( margin: EdgeInsets.only(bottom: showHeader ? 8 : 0), decoration: BoxDecoration( @@ -83,8 +127,8 @@ class PostCard extends StatelessWidget { border: showBorder ? const Border( - bottom: BorderSide(color: AppColors.borderWarm, width: 0.5), - ) + bottom: BorderSide(color: AppColors.borderWarm, width: 0.5), + ) : null, ), child: Padding( @@ -174,10 +218,7 @@ class PostCard extends StatelessWidget { ], // Spacing after title - if (post.post.title != null && - (post.post.embed?.external != null || - post.post.embed?.blueskyPost != null || - post.post.text.isNotEmpty)) + if (post.post.title != null && hasContentBelowTitle) const SizedBox(height: 12), ], ), @@ -195,12 +236,12 @@ class PostCard extends StatelessWidget { letterSpacing: -0.3, ), ), - if (post.post.embed?.external != null || - post.post.embed?.blueskyPost != null || - post.post.text.isNotEmpty) - const SizedBox(height: 12), + if (hasContentBelowTitle) const SizedBox(height: 12), ], + // Native image/video embed + if (media != null) ...[media, const SizedBox(height: 8)], + // Embed thumbnail if (post.post.embed?.external != null) ...[ _EmbedCard( @@ -320,6 +361,175 @@ class PostCard extends StatelessWidget { } } + /// Builds the native media block for image and video embeds. + /// + /// Returns null when there is nothing renderable — no embed, an unknown + /// `$type`, or an unhydrated record shape whose blobs never resolved — so + /// the card falls back to text rather than showing an empty frame. + Widget? _buildMediaEmbed(BuildContext context) { + return switch (post.post.embed) { + final ImagesPostEmbed images => _buildImagesEmbed(context, images), + final VideoPostEmbed video => _buildVideoEmbed(context, video), + _ => null, + }; + } + + /// Builds the images block: the first image at full card width, with a + /// "1/N" badge when the gallery holds more. Tapping opens 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'), + onTap: disableNavigation ? null : () => _navigateToDetail(context), + 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: 'Open post', + child: block, + ); + } + + /// Builds the video block: thumbnail or dark placeholder, a play overlay, + /// and the duration when the record carried one. + 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(), + ); + + 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: 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, + ), + ); + } + /// Builds the community handle with styled parts (name + instance) Widget _buildCommunityHandle(CommunityRef community) { final displayHandle = CommunityHandleUtils.formatHandleForDisplay( @@ -503,6 +713,56 @@ class PostCard extends StatelessWidget { } } +/// 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 /// /// Shows a thumbnail image for external embeds with loading and error states. diff --git a/pubspec.lock b/pubspec.lock index fd05105..71148c1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -318,6 +318,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -432,6 +437,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -600,6 +610,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: @@ -864,6 +879,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: "direct main" description: @@ -1101,6 +1124,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1317,6 +1348,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ba12f43..cd7d03f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter url_launcher_platform_interface: ^2.3.2 # The "flutter_lints" package below contains a set of recommended lints to diff --git a/test/models/post_embed_test.dart b/test/models/post_embed_test.dart index ff8abfb..b82d036 100644 --- a/test/models/post_embed_test.dart +++ b/test/models/post_embed_test.dart @@ -2,6 +2,10 @@ import 'package:coves_flutter/constants/embed_types.dart'; import 'package:coves_flutter/models/post.dart'; import 'package:flutter_test/flutter_test.dart'; +const _thumbUrl = 'https://cdn.test/thumb.jpg'; +const _fullUrl = 'https://cdn.test/full.jpg'; +const _videoUrl = 'https://cdn.test/video.mp4'; + void main() { group('PostEmbed.fromJson', () { final externalPayload = { @@ -25,6 +29,7 @@ void main() { ...externalPayload, }); + expect(embed, isA()); expect(embed.external, isNotNull); expect(embed.external!.uri, 'https://example.com/article'); expect(embed.blueskyPost, isNull); @@ -36,10 +41,12 @@ void main() { ...externalPayload, }); + expect(embed, isA()); expect(embed.external, isNotNull); expect(embed.external!.uri, 'https://example.com/article'); expect(embed.external!.thumb, contains('getBlob')); expect(embed.blueskyPost, isNull); + expect(embed.type, EmbedTypes.externalView); }); test('parses record-form post embed', () { @@ -48,6 +55,7 @@ void main() { ...postPayload, }); + expect(embed, isA()); expect(embed.blueskyPost, isNotNull); expect(embed.blueskyPost!.uri, 'at://did:plc:xyz/app.bsky.feed.post/abc'); expect(embed.external, isNull); @@ -59,9 +67,11 @@ void main() { ...postPayload, }); + expect(embed, isA()); expect(embed.blueskyPost, isNotNull); expect(embed.blueskyPost!.uri, 'at://did:plc:xyz/app.bsky.feed.post/abc'); expect(embed.external, isNull); + expect(embed.type, EmbedTypes.postView); }); test('falls back to external for unrecognized type with top-level uri', () { @@ -70,8 +80,1122 @@ void main() { 'uri': 'https://example.com/bare-link', }); + expect(embed, isA()); expect(embed.external, isNotNull); expect(embed.external!.uri, 'https://example.com/bare-link'); }); }); + + group('PostEmbed.fromJson images#view', () { + test('parses a single image with every field populated', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + { + 'thumb': _thumbUrl, + 'fullsize': _fullUrl, + 'alt': 'A red barn at dusk', + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ], + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.imagesView); + + final image = (embed as ImagesPostEmbed).images.single; + expect(image.thumb, _thumbUrl); + expect(image.fullsize, _fullUrl); + expect(image.alt, 'A red barn at dusk'); + expect(image.aspectRatio, isNotNull); + expect(image.aspectRatio!.width, 3); + expect(image.aspectRatio!.height, 2); + }); + + test('preserves the order of a multi-image embed', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': 'https://cdn.test/t1.jpg', 'fullsize': _fullUrl}, + {'thumb': 'https://cdn.test/t2.jpg', 'fullsize': _fullUrl}, + {'thumb': 'https://cdn.test/t3.jpg', 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA()); + + final images = (embed as ImagesPostEmbed).images; + expect(images, hasLength(3)); + expect(images.map((image) => image.thumb).toList(), const [ + 'https://cdn.test/t1.jpg', + 'https://cdn.test/t2.jpg', + 'https://cdn.test/t3.jpg', + ]); + }); + + test('parses a minimal image with only thumb and fullsize', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA()); + + final image = (embed as ImagesPostEmbed).images.single; + expect(image.thumb, _thumbUrl); + expect(image.fullsize, _fullUrl); + expect(image.alt, isNull); + expect(image.aspectRatio, isNull); + }); + + test('drops a malformed aspectRatio but keeps the image', () { + const malformedRatios = >[ + {'width': 3}, + {'height': 2}, + {'width': '3', 'height': 2}, + {'width': 3, 'height': '2'}, + {'width': 3, 'height': 0}, + {'width': 0, 'height': 2}, + {'width': -3, 'height': 2}, + {}, + ]; + + for (final ratio in malformedRatios) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl, 'aspectRatio': ratio}, + ], + }); + + expect(embed, isA(), reason: 'ratio: $ratio'); + + final image = (embed as ImagesPostEmbed).images.single; + expect(image.aspectRatio, isNull, reason: 'ratio: $ratio'); + expect(image.thumb, _thumbUrl, reason: 'ratio: $ratio'); + expect(image.fullsize, _fullUrl, reason: 'ratio: $ratio'); + } + }); + + test('drops a non-map aspectRatio but keeps the image', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl, 'aspectRatio': '3:2'}, + ], + }); + + expect(embed, isA()); + expect((embed as ImagesPostEmbed).images.single.aspectRatio, isNull); + }); + + test('ignores a non-String alt rather than dropping the image', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl, 'alt': 42}, + ], + }); + + expect(embed, isA()); + expect((embed as ImagesPostEmbed).images.single.alt, isNull); + }); + }); + + group('PostEmbed.fromJson images#view is all-or-nothing', () { + test('rejects the whole embed when one entry carries a blob map', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + {'image': _blobRef('bafyimage2'), 'alt': 'unhydrated'}, + ], + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.imagesView); + expect(embed.data['images'], hasLength(2)); + }); + + test('rejects the whole embed when fullsize is a blob map', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _blobRef('bafyfull')}, + ], + }); + + expect(embed, isA()); + }); + + test('rejects the whole embed when thumb is missing', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'fullsize': _fullUrl, 'alt': 'no thumb'}, + ], + }); + + expect(embed, isA()); + }); + + test('rejects the whole embed when an entry is not a map', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + _fullUrl, + ], + }); + + expect(embed, isA()); + }); + + test('treats an empty images list as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [], + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.imagesView); + }); + + test('treats a missing images key as unknown', () { + final embed = PostEmbed.fromJson({r'$type': EmbedTypes.imagesView}); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.imagesView); + }); + + test('treats a non-list images value as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + }); + + expect(embed, isA()); + }); + }); + + group('PostEmbed.fromJson images record shape', () { + test('treats the unhydrated images record as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.images, + 'images': [ + { + 'image': _blobRef('bafyimage1'), + 'alt': 'A red barn at dusk', + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ], + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.images); + expect(embed.external, isNull); + expect(embed.blueskyPost, isNull); + }); + + test('treats an images record with url strings as unknown', () { + // The record shape never carries rendered URLs; even if it did, the + // client must not render it because the $type lacks #view. + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.images, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA()); + }); + }); + + group('PostEmbed.fromJson video#view', () { + test('parses a video with every field populated', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'thumbnail': _thumbUrl, + 'alt': 'A timelapse of the harbour', + 'duration': 42, + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.videoView); + + final video = embed as VideoPostEmbed; + expect(video.video, _videoUrl); + expect(video.thumbnail, _thumbUrl); + expect(video.alt, 'A timelapse of the harbour'); + expect(video.duration, 42); + }); + + test('parses a minimal video with only the video url', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + }); + + expect(embed, isA()); + + final video = embed as VideoPostEmbed; + expect(video.video, _videoUrl); + expect(video.thumbnail, isNull); + expect(video.alt, isNull); + expect(video.duration, isNull); + }); + + test('treats a blob map video field as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _blobRef('bafyvideo'), + 'thumbnail': _thumbUrl, + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.videoView); + }); + + test('treats a missing video field as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'thumbnail': _thumbUrl, + 'duration': 42, + }); + + expect(embed, isA()); + }); + + test('treats a blob map thumbnail as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'thumbnail': _blobRef('bafythumb'), + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.videoView); + }); + + test('ignores a non-int duration rather than rejecting the embed', () { + const durations = ['long', 42.5, true]; + + for (final duration in durations) { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'duration': duration, + }), + returnsNormally, + reason: 'duration: $duration', + ); + + expect(embed, isA(), reason: 'duration: $duration'); + + final video = embed as VideoPostEmbed; + expect(video.video, _videoUrl, reason: 'duration: $duration'); + expect(video.duration, isNull, reason: 'duration: $duration'); + } + }); + + test('ignores a non-String alt rather than rejecting the embed', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'alt': 42, + }); + + expect(embed, isA()); + + final video = embed as VideoPostEmbed; + expect(video.video, _videoUrl); + expect(video.alt, isNull); + }); + }); + + group('PostEmbed.fromJson video record shape', () { + test('treats the unhydrated video record as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.video, + 'video': _blobRef('bafyvideo'), + 'thumbnail': _blobRef('bafythumb'), + 'alt': 'A timelapse of the harbour', + 'duration': 42, + }); + + expect(embed, isA()); + expect(embed.type, EmbedTypes.video); + expect(embed.external, isNull); + expect(embed.blueskyPost, isNull); + }); + + test('treats a video record with a url string as unknown', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.video, + 'video': _videoUrl, + }); + + expect(embed, isA()); + }); + }); + + group('PostEmbed bridge getters', () { + test('are both null on an images embed', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA()); + expect(embed.external, isNull); + expect(embed.blueskyPost, isNull); + }); + + test('are both null on a video embed', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + }); + + expect(embed, isA()); + expect(embed.external, isNull); + expect(embed.blueskyPost, isNull); + }); + + test('are both null on an unknown embed', () { + final embed = PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'payload': {'a': 1}, + }); + + expect(embed, isA()); + expect(embed.external, isNull); + expect(embed.blueskyPost, isNull); + }); + + test('expose only the matching payload on external and quote embeds', () { + final external = PostEmbed.fromJson({ + r'$type': EmbedTypes.externalView, + 'external': {'uri': 'https://example.com/article'}, + }); + final quote = PostEmbed.fromJson({ + r'$type': EmbedTypes.postView, + 'post': { + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', + 'cid': 'bafyrei123', + }, + }); + + expect(external.external, isNotNull); + expect(external.blueskyPost, isNull); + expect(quote.blueskyPost, isNotNull); + expect(quote.external, isNull); + }); + }); + + group('PostEmbed legacy uri fallback', () { + test('fires for a truly unknown type carrying a top-level uri', () { + final embed = PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'uri': 'https://example.com/bare-link', + 'title': 'Bare link', + }); + + expect(embed, isA()); + expect(embed.external!.uri, 'https://example.com/bare-link'); + expect(embed.external!.title, 'Bare link'); + }); + + test('does not fire for a malformed images#view carrying a uri', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'uri': 'https://example.com/not-a-link-embed', + 'images': [ + {'image': _blobRef('bafyimage1')}, + ], + }); + + expect(embed, isA()); + expect(embed.external, isNull); + expect(embed.type, EmbedTypes.imagesView); + }); + + test('does not fire for a malformed video#view carrying a uri', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'uri': 'https://example.com/not-a-link-embed', + 'video': _blobRef('bafyvideo'), + }); + + expect(embed, isA()); + expect(embed.external, isNull); + }); + + test('does not fire for an images record carrying a uri', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.images, + 'uri': 'https://example.com/not-a-link-embed', + 'images': [ + {'image': _blobRef('bafyimage1')}, + ], + }); + + expect(embed, isA()); + expect(embed.external, isNull); + }); + + test('does not fire for a video record carrying a uri', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.video, + 'uri': 'https://example.com/not-a-link-embed', + 'video': _blobRef('bafyvideo'), + }); + + expect(embed, isA()); + expect(embed.external, isNull); + }); + }); + + group('PostEmbed.fromJson never throws', () { + test('returns unknown for an unrecognized type with no uri', () { + final embed = PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'payload': {'a': 1}, + }); + + expect(embed, isA()); + expect(embed.type, 'social.coves.embed.somethingNew'); + expect(embed.data['payload'], {'a': 1}); + }); + + test('returns unknown with type "unknown" when the type is missing', () { + final embed = PostEmbed.fromJson({'stray': 'value'}); + + expect(embed, isA()); + expect(embed.type, 'unknown'); + expect(embed.data['stray'], 'value'); + }); + + test('returns unknown for an empty map', () { + final embed = PostEmbed.fromJson({}); + + expect(embed, isA()); + expect(embed.type, 'unknown'); + expect(embed.data, isEmpty); + }); + + test('returns unknown when a post embed is missing its post ref', () { + late PostEmbed embed; + + expect( + () => embed = PostEmbed.fromJson({r'$type': EmbedTypes.postView}), + returnsNormally, + ); + expect(embed, isA()); + expect(embed.blueskyPost, isNull); + }); + + test('preserves the raw json in data on a parsed variant', () { + final json = { + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'duration': 42, + }; + final embed = PostEmbed.fromJson(json); + + expect(embed, isA()); + expect(embed.data, same(json)); + }); + }); + + group('H2 media url scheme allowlist', () { + // The AppView's HydrateView no-ops on `#view` $types and the firehose + // consumer stores embeds verbatim, so a federated repo can publish a + // pre-stamped view carrying arbitrary URLs. The model is the last line + // of defence: anything that is not a non-empty http(s) URI poisons the + // whole embed, mirroring UrlLauncher's allowlist. + const rejectedUrls = [ + 'file:///etc/passwd', + 'content://com.android.providers/media/1', + 'javascript:alert(1)', + 'data:image/png;base64,iVBORw0KGgo=', + 'ftp://cdn.test/f1.jpg', + 'at://did:plc:xyz/blob/bafy', + '/relative/path.jpg', + 'cdn.test/no-scheme.jpg', + '', + ]; + + test('rejects the embed when an image thumb is not http(s)', () { + for (final url in rejectedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': url, 'fullsize': 'https://cdn.test/f1.jpg'}, + ], + }); + + expect(embed, isA(), reason: 'thumb: "$url"'); + expect(embed.type, EmbedTypes.imagesView, reason: 'thumb: "$url"'); + } + }); + + test('rejects the embed when an image fullsize is not http(s)', () { + for (final url in rejectedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': 'https://cdn.test/t1.jpg', 'fullsize': url}, + ], + }); + + expect(embed, isA(), reason: 'fullsize: "$url"'); + } + }); + + test('rejects the whole gallery when only one entry is bad', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': 'https://cdn.test/t1.jpg', 'fullsize': _fullUrl}, + {'thumb': 'https://cdn.test/t2.jpg', 'fullsize': _fullUrl}, + {'thumb': 'file:///etc/passwd', 'fullsize': _fullUrl}, + ], + }); + + expect( + embed, + isA(), + reason: 'all-or-nothing: one hostile url poisons the gallery', + ); + }); + + test('accepts http and https image urls', () { + const acceptedUrls = [ + 'https://cdn.test/f1.jpg', + 'http://cdn.test/f1.jpg', + 'HTTPS://cdn.test/f1.jpg', + 'https://cdn.test/f1.jpg?sig=abc&w=1600', + ]; + + for (final url in acceptedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': url, 'fullsize': url}, + ], + }); + + expect(embed, isA(), reason: 'url: "$url"'); + expect( + (embed as ImagesPostEmbed).images.single.thumb, + url, + reason: 'the url is preserved verbatim, not rewritten', + ); + } + }); + + test('rejects the embed when the video url is not http(s)', () { + for (final url in rejectedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': url, + }); + + expect(embed, isA(), reason: 'video: "$url"'); + expect(embed.type, EmbedTypes.videoView, reason: 'video: "$url"'); + } + }); + + test('rejects the embed when a present thumbnail is not http(s)', () { + for (final url in rejectedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'thumbnail': url, + }); + + expect(embed, isA(), reason: 'thumbnail: "$url"'); + } + }); + + test('accepts a video with no thumbnail at all', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + }); + + expect(embed, isA()); + expect((embed as VideoPostEmbed).thumbnail, isNull); + }); + + test('accepts http and https video urls', () { + const acceptedUrls = [ + 'https://cdn.test/video.mp4', + 'http://cdn.test/video.mp4', + ]; + + for (final url in acceptedUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': url, + 'thumbnail': url, + }); + + expect(embed, isA(), reason: 'url: "$url"'); + expect((embed as VideoPostEmbed).video, url); + } + }); + + test('still never throws on a hostile url', () { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': 'ht tp://bad url', 'fullsize': _fullUrl}, + ], + }), + returnsNormally, + ); + expect(embed, isA()); + }); + }); + + group('H7 fromJson never throws on the legacy branches', () { + // ExternalEmbed.fromJson and EmbedSource.fromJson are full of unchecked + // casts. A federated record only has to disagree about one field's type + // for a TypeError — an Error, not an Exception — to escape. + + test('degrades an external embed with no uri', () { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.externalView, + 'external': {}, + }), + returnsNormally, + ); + expect(embed, isA()); + expect(embed.external, isNull); + }); + + test('degrades an external embed whose fields have the wrong types', () { + const hostileExternals = >[ + {'uri': 42}, + {'uri': 'https://example.com/a', 'title': 42}, + {'uri': 'https://example.com/a', 'description': 42}, + {'uri': 'https://example.com/a', 'thumb': 42}, + {'uri': 'https://example.com/a', 'domain': 42}, + {'uri': 'https://example.com/a', 'totalCount': 'x'}, + ]; + + for (final external in hostileExternals) { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.externalView, + 'external': external, + }), + returnsNormally, + reason: 'external: $external', + ); + expect(embed, isA(), reason: 'external: $external'); + } + }); + + test('degrades an external embed with a malformed source', () { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.externalView, + 'external': { + 'uri': 'https://example.com/megathread', + 'sources': [ + {'uri': 'https://example.com/ok', 'title': 'Fine'}, + {'title': 'No uri at all'}, + ], + }, + }), + returnsNormally, + reason: 'EmbedSource.fromJson throws FormatException on a bad source', + ); + expect(embed, isA()); + }); + + test('degrades a legacy uri-fallback payload with a bad field', () { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'uri': 'https://example.com/bare-link', + 'title': 42, + }), + returnsNormally, + reason: 'the fallback branch casts just as unsafely as the typed one', + ); + expect(embed, isA()); + }); + + test('degrades a quote embed whose resolved post is not a map', () { + late PostEmbed embed; + + expect( + () => + embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.postView, + 'post': { + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', + 'cid': 'bafyrei123', + }, + 'resolved': 'not-a-map', + }), + returnsNormally, + reason: '`resolved as Map` throws TypeError past on FormatException', + ); + expect(embed, isA()); + }); + }); + + group('H7 TimelineResponse survives a hostile feed item', () { + Map feedItem({Map? embed}) { + return { + 'post': { + 'uri': 'at://did:example/post/123', + 'cid': 'cid123', + 'rkey': '123', + 'author': {'did': 'did:plc:author', 'handle': 'author.test'}, + 'community': {'did': 'did:plc:community', 'name': 'test-community'}, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'indexedAt': '2024-01-01T00:00:00.000Z', + 'stats': { + 'upvotes': 0, + 'downvotes': 0, + 'score': 0, + 'commentCount': 0, + }, + if (embed != null) 'embed': embed, + }, + }; + } + + test('keeps the good post when a sibling embed is hostile', () { + late TimelineResponse response; + + expect( + () => + response = TimelineResponse.fromJson({ + 'feed': [ + feedItem(), + feedItem( + embed: { + r'$type': EmbedTypes.externalView, + 'external': {'uri': 42}, + }, + ), + ], + }), + returnsNormally, + reason: 'one hostile federated record must not blank the feed', + ); + expect( + response.feed, + hasLength(greaterThanOrEqualTo(1)), + reason: 'the valid post survives whatever happens to its neighbour', + ); + }); + + test('treats a non-list feed as empty', () { + late TimelineResponse response; + + expect( + () => response = TimelineResponse.fromJson({'feed': 'not-a-list'}), + returnsNormally, + ); + expect(response.feed, isEmpty); + }); + + test('skips a non-map feed item', () { + late TimelineResponse response; + + expect( + () => + response = TimelineResponse.fromJson({ + 'feed': ['not-a-map', feedItem()], + }), + returnsNormally, + ); + expect(response.feed, hasLength(1)); + }); + + test('preserves the cursor alongside a hostile item', () { + late TimelineResponse response; + + expect( + () => + response = TimelineResponse.fromJson({ + 'feed': [ + feedItem( + embed: { + r'$type': EmbedTypes.postView, + 'post': {'uri': 'at://x/y/z', 'cid': 'c'}, + 'resolved': 'not-a-map', + }, + ), + ], + 'cursor': 'next-page', + }), + returnsNormally, + ); + expect(response.cursor, 'next-page'); + }); + + test('treats a non-String cursor as absent rather than throwing', () { + late TimelineResponse response; + + expect( + () => response = TimelineResponse.fromJson({'feed': [], 'cursor': 42}), + returnsNormally, + ); + expect(response.cursor, isNull); + }); + }); + + group('H8 release-mode invariants', () { + test('ImagesPostEmbed rejects an empty image list', () { + expect( + () => ImagesPostEmbed( + type: EmbedTypes.imagesView, + images: const [], + data: const {}, + ), + throwsArgumentError, + reason: 'asserts are stripped in release; this must always throw', + ); + }); + + test('EmbedAspectRatio rejects dimensions below 1', () { + expect(() => EmbedAspectRatio(width: 0, height: 2), throwsArgumentError); + expect(() => EmbedAspectRatio(width: 3, height: 0), throwsArgumentError); + expect(() => EmbedAspectRatio(width: -3, height: 2), throwsArgumentError); + }); + + test('a parsed images list cannot be mutated', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA()); + expect( + () => (embed as ImagesPostEmbed).images.clear(), + throwsUnsupportedError, + ); + }); + }); + + group('H9 media urls need a real host', () { + const hostlessUrls = [ + 'http:foo', + 'https:///path.jpg', + 'http://', + 'https://', + ]; + + test('rejects an image whose url has no authority', () { + for (final url in hostlessUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': url, 'fullsize': _fullUrl}, + ], + }); + + expect(embed, isA(), reason: 'thumb: "$url"'); + } + }); + + test('rejects a fullsize whose url has no authority', () { + for (final url in hostlessUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': url}, + ], + }); + + expect(embed, isA(), reason: 'fullsize: "$url"'); + } + }); + + test('rejects a video whose url has no authority', () { + for (final url in hostlessUrls) { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': url, + }); + + expect(embed, isA(), reason: 'video: "$url"'); + } + }); + }); + + group('H10 anti-DoS caps', () { + List> gallery(int count) { + return List.generate( + count, + (index) => { + 'thumb': 'https://cdn.test/t$index.jpg', + 'fullsize': 'https://cdn.test/f$index.jpg', + }, + ); + } + + test('accepts the lexicon maximum of 8 images', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': gallery(8), + }); + + expect(embed, isA()); + expect((embed as ImagesPostEmbed).images, hasLength(8)); + }); + + test('rejects a gallery of 9 images outright', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': gallery(9), + }); + + expect( + embed, + isA(), + reason: 'the appview never serves >8, so >8 is a hostile record', + ); + }); + + test('rejects an absurd gallery without hanging', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': gallery(5000), + }); + + expect(embed, isA()); + }); + + test('truncates an oversized image alt to 10000 chars', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl, 'alt': 'a' * 20000}, + ], + }); + + expect(embed, isA()); + expect((embed as ImagesPostEmbed).images.single.alt, hasLength(10000)); + }); + + test('truncates an oversized video alt to 10000 chars', () { + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + 'alt': 'a' * 20000, + }); + + expect(embed, isA()); + expect((embed as VideoPostEmbed).alt, hasLength(10000)); + }); + + test('leaves an alt at the limit untouched', () { + final alt = 'a' * 10000; + final embed = PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl, 'alt': alt}, + ], + }); + + expect(embed, isA()); + expect((embed as ImagesPostEmbed).images.single.alt, alt); + }); + }); + + group('PostEmbed sealed union', () { + test('supports an exhaustive switch over all five variants', () { + final embeds = [ + PostEmbed.fromJson({ + r'$type': EmbedTypes.imagesView, + 'images': [ + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + {'thumb': _thumbUrl, 'fullsize': _fullUrl}, + ], + }), + PostEmbed.fromJson({ + r'$type': EmbedTypes.videoView, + 'video': _videoUrl, + }), + PostEmbed.fromJson({ + r'$type': EmbedTypes.externalView, + 'external': {'uri': 'https://example.com/article'}, + }), + PostEmbed.fromJson({ + r'$type': EmbedTypes.postView, + 'post': { + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', + 'cid': 'bafyrei123', + }, + }), + PostEmbed.fromJson({r'$type': 'social.coves.embed.somethingNew'}), + ]; + + expect(embeds.map(_describeEmbed).toList(), [ + 'images:2', + 'video:$_videoUrl', + 'external', + 'quote', + 'unknown', + ]); + }); + }); } + +/// Exhaustive switch over the sealed [PostEmbed] hierarchy. +/// +/// This deliberately omits a default clause: it only compiles while +/// [PostEmbed] is sealed and these five variants are its only subtypes. +String _describeEmbed(PostEmbed embed) => switch (embed) { + ImagesPostEmbed(:final images) => 'images:${images.length}', + VideoPostEmbed(:final video) => 'video:$video', + ExternalPostEmbed() => 'external', + QuotePostEmbed() => 'quote', + UnknownPostEmbed() => 'unknown', +}; + +/// An atproto blob reference, as served when appview hydration fails. +Map _blobRef(String link) => { + r'$type': 'blob', + 'ref': {r'$link': link}, + 'mimeType': 'image/jpeg', + 'size': 123456, +}; diff --git a/test/test_helpers/fake_providers.dart b/test/test_helpers/fake_providers.dart new file mode 100644 index 0000000..81c0efa --- /dev/null +++ b/test/test_helpers/fake_providers.dart @@ -0,0 +1,85 @@ +import 'package:coves_flutter/providers/auth_provider.dart'; +import 'package:coves_flutter/providers/block_provider.dart'; +import 'package:coves_flutter/providers/community_subscription_provider.dart'; +import 'package:coves_flutter/providers/vote_provider.dart'; +import 'package:coves_flutter/services/coves_api_service.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/services/vote_service.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Test doubles for the providers a `PostCard` subtree resolves. +/// +/// These deliberately extend the real provider classes rather than +/// standing alone: PostCardActions looks its dependencies up by concrete +/// type (including a `Consumer3`), so a lookalike ChangeNotifier registered under its own +/// type throws ProviderNotFoundException at build time. + +/// AuthProvider that reports a signed-out user and never touches secure +/// storage or the network. +class FakeAuthProvider extends AuthProvider { + @override + bool get isAuthenticated => false; + + @override + bool get isLoading => false; +} + +/// VoteProvider wired to a VoteService with no session and no DID, so vote +/// calls resolve locally instead of hitting the API. +class FakeVoteProvider extends VoteProvider { + FakeVoteProvider(AuthProvider authProvider) + : super( + voteService: VoteService( + sessionGetter: () async => null, + didGetter: () => null, + ), + authProvider: authProvider, + ); +} + +/// CommunitySubscriptionProvider whose initial load is a no-op, so no +/// pending timers or network calls leak into a test. +class FakeSubscriptionProvider extends CommunitySubscriptionProvider { + FakeSubscriptionProvider({required super.authProvider, super.apiService}); + + @override + Future loadSubscribedCommunities() async {} +} + +/// An API service with no token, for providers that require one but whose +/// requests are never expected to resolve in a widget test. +CovesApiService tokenlessApiService() => + CovesApiService(tokenGetter: () async => null); + +/// The full provider set a `PostCard` needs to build. +/// +/// Pass a `streamableService` to inject a Dio-mocked service; otherwise a +/// real one is supplied that is never exercised. +List postCardProviders({ + required AuthProvider auth, + StreamableService? streamableService, +}) { + return [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => FakeVoteProvider(auth)), + ChangeNotifierProvider( + create: + (_) => FakeSubscriptionProvider( + authProvider: auth, + apiService: tokenlessApiService(), + ), + ), + ChangeNotifierProvider( + create: + (_) => BlockProvider( + apiService: tokenlessApiService(), + authProvider: auth, + ), + ), + Provider.value( + value: streamableService ?? StreamableService(), + ), + ]; +} diff --git a/test/test_helpers/mock_providers.dart b/test/test_helpers/mock_providers.dart deleted file mode 100644 index 8756e70..0000000 --- a/test/test_helpers/mock_providers.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:coves_flutter/models/coves_session.dart'; -import 'package:coves_flutter/providers/vote_provider.dart'; -import 'package:flutter/foundation.dart'; - -/// Mock CommentsProvider for testing -class MockCommentsProvider extends ChangeNotifier { - final String postUri; - final String postCid; - - MockCommentsProvider({ - required this.postUri, - required this.postCid, - }); - - final ValueNotifier currentTimeNotifier = ValueNotifier(null); - - @override - void dispose() { - currentTimeNotifier.dispose(); - super.dispose(); - } -} - -/// Mock AuthProvider for testing -class MockAuthProvider extends ChangeNotifier { - bool _isAuthenticated = false; - bool _isLoading = false; - String? _error; - String? _did; - String? _handle; - CovesSession? _session; - - bool get isAuthenticated => _isAuthenticated; - bool get isLoading => _isLoading; - String? get error => _error; - String? get did => _did; - String? get handle => _handle; - CovesSession? get session => _session; - - void setAuthenticated({required bool value, String? did}) { - _isAuthenticated = value; - _did = did ?? 'did:plc:testuser'; - notifyListeners(); - } - - Future signIn(String handle) async { - _isAuthenticated = true; - _handle = handle; - _did = 'did:plc:testuser'; - notifyListeners(); - } - - Future signOut() async { - _isAuthenticated = false; - _did = null; - _handle = null; - _session = null; - notifyListeners(); - } - - Future initialize() async { - _isLoading = false; - } - - Future getAccessToken() async { - return _isAuthenticated ? 'mock_access_token' : null; - } - - void clearError() { - _error = null; - notifyListeners(); - } -} - -/// Mock VoteProvider for testing -class MockVoteProvider extends ChangeNotifier { - final Map _votes = {}; - final Map _scoreAdjustments = {}; - final Map _pendingRequests = {}; - - bool isLiked(String postUri) { - return _votes[postUri]?.direction == 'up' && - !(_votes[postUri]?.deleted ?? false); - } - - int getAdjustedScore(String postUri, int originalScore) { - final adjustment = _scoreAdjustments[postUri] ?? 0; - return originalScore + adjustment; - } - - VoteState? getVoteState(String postUri) => _votes[postUri]; - - bool isPending(String postUri) => _pendingRequests[postUri] ?? false; - - Future toggleVote({ - required String postUri, - required String postCid, - String direction = 'up', - }) async { - final currentlyLiked = isLiked(postUri); - - if (currentlyLiked) { - // Removing vote - _votes[postUri] = VoteState(direction: direction, deleted: true); - _scoreAdjustments[postUri] = (_scoreAdjustments[postUri] ?? 0) - 1; - } else { - // Adding vote - _votes[postUri] = VoteState(direction: direction, deleted: false); - _scoreAdjustments[postUri] = (_scoreAdjustments[postUri] ?? 0) + 1; - } - - notifyListeners(); - return !currentlyLiked; - } - - void setVoteState({required String postUri, required bool liked}) { - if (liked) { - _votes[postUri] = const VoteState(direction: 'up', deleted: false); - } else { - _votes.remove(postUri); - } - notifyListeners(); - } - - void setInitialVoteState({ - required String postUri, - String? voteDirection, - String? voteUri, - }) { - if (voteDirection != null) { - String? rkey; - if (voteUri != null) { - final parts = voteUri.split('/'); - if (parts.isNotEmpty) { - rkey = parts.last; - } - } - - _votes[postUri] = VoteState( - direction: voteDirection, - uri: voteUri, - rkey: rkey, - deleted: false, - ); - - _scoreAdjustments.remove(postUri); - } else { - _votes.remove(postUri); - } - } - - void clear() { - _votes.clear(); - _pendingRequests.clear(); - _scoreAdjustments.clear(); - notifyListeners(); - } -} diff --git a/test/widgets/detailed_post_view_media_test.dart b/test/widgets/detailed_post_view_media_test.dart new file mode 100644 index 0000000..ff9f756 --- /dev/null +++ b/test/widgets/detailed_post_view_media_test.dart @@ -0,0 +1,1173 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/widgets/bluesky_post_card.dart'; +import 'package:coves_flutter/widgets/detailed_post_view.dart'; +import 'package:coves_flutter/widgets/external_link_bar.dart'; +import 'package:coves_flutter/widgets/fullscreen_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const _imagesKey = Key('detail-images-embed'); +const _pageIndicatorKey = Key('detail-images-page-indicator'); +const _videoKey = Key('detail-video-embed'); +const _playOverlayKey = Key('detail-video-play-overlay'); +const _durationBadgeKey = Key('detail-video-duration-badge'); +const _imageViewerKey = Key('detail-image-viewer'); + +const _thumb1 = 'https://cdn.test/t1.jpg'; +const _thumb2 = 'https://cdn.test/t2.jpg'; +const _thumb3 = 'https://cdn.test/t3.jpg'; +const _full1 = 'https://cdn.test/f1.jpg'; +const _full2 = 'https://cdn.test/f2.jpg'; +const _full3 = 'https://cdn.test/f3.jpg'; +const _videoUrl = 'https://cdn.test/video.mp4'; +const _videoThumb = 'https://cdn.test/video-thumb.jpg'; + +/// 16:9 as a width/height double — the fallback ratio when none is given. +const _defaultRatio = 16 / 9; + +/// H1 clamp bounds for detail-view media, as width/height doubles. +const _minDetailRatio = 1 / 3; +const _maxDetailRatio = 3.0; + +/// Records routes pushed onto the navigator so a synchronous +/// `Navigator.push` can be inspected — and, for the video player, +/// inspected *without* mounting the pushed page. +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 a plain + /// `whereType>()` would count it too. Callers + /// clear the log after pumping, and this getter is what they assert on. + List> get pushedPages => + pushed.whereType>().toList(); + + /// Forgets routes recorded during setup, so only taps are counted. + void reset() => pushed.clear(); +} + +void main() { + late _RecordingObserver observer; + + setUp(() { + observer = _RecordingObserver(); + }); + + /// A tall phone surface. DetailedPostView is an unbounded Column, so the + /// harness scrolls it exactly like the real SliverList in + /// post_detail_screen.dart; the extra height just keeps media on screen + /// so taps land without an explicit scroll. + void useDetailSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(400, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Widget harness(FeedViewPost post) { + return MultiProvider( + providers: [ + Provider.value(value: StreamableService()), + ], + child: MaterialApp( + navigatorObservers: [observer], + home: Scaffold( + body: SingleChildScrollView(child: DetailedPostView(post: post)), + ), + ), + ); + } + + FeedViewPost makePost({ + PostEmbed? embed, + String? title = 'Test Post Title', + String text = '', + String uri = 'at://did:example/post/123', + }) { + return FeedViewPost( + post: PostView( + uri: uri, + 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: PostRecord(title: title, content: text), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: embed, + ), + ); + } + + /// Native `images#view` built through the real model parser. + PostEmbed imagesEmbed(List> images) { + return PostEmbed.fromJson({ + r'$type': 'social.coves.embed.images#view', + 'images': images, + }); + } + + PostEmbed videoEmbed({ + String video = _videoUrl, + String? thumbnail, + String? alt, + int? duration, + }) { + return PostEmbed.fromJson({ + r'$type': 'social.coves.embed.video#view', + 'video': video, + if (thumbnail != null) 'thumbnail': thumbnail, + if (alt != null) 'alt': alt, + if (duration != null) 'duration': duration, + }); + } + + /// Legacy `external#view` embed — the shape slice C must not regress. + PostEmbed externalEmbed({ + String uri = 'https://example.com/article', + String? thumb, + String? embedType, + String? provider, + List>? images, + }) { + return PostEmbed.fromJson({ + r'$type': 'social.coves.embed.external#view', + 'external': { + 'uri': uri, + if (thumb != null) 'thumb': thumb, + if (embedType != null) 'embedType': embedType, + if (provider != null) 'provider': provider, + if (images != null) 'images': images, + }, + }); + } + + Finder inBlock(Key blockKey, Finder matching) { + return find.descendant( + of: find.byKey(blockKey), + matching: matching, + matchRoot: true, + ); + } + + double renderedAspectRatio(WidgetTester tester, Key blockKey) { + final finder = inBlock(blockKey, find.byType(AspectRatio)); + expect( + finder, + findsWidgets, + reason: 'media block $blockKey should size itself with an AspectRatio', + ); + return tester.widget(finder.first).aspectRatio; + } + + List imageUrlsIn(WidgetTester tester, Key blockKey) { + return tester + .widgetList( + inBlock(blockKey, find.byType(CachedNetworkImage)), + ) + .map((image) => image.imageUrl) + .toList(); + } + + group('C1 legacy external rendering is untouched', () { + testWidgets('single external image still renders thumb and link bar', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: externalEmbed(thumb: _thumb1)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + final images = tester + .widgetList(find.byType(CachedNetworkImage)) + .map((image) => image.imageUrl); + expect( + images, + contains(_thumb1), + reason: 'external single image renders its thumb, not a fullsize', + ); + expect(find.text('example.com/article'), findsOneWidget); + expect(find.byIcon(Icons.open_in_new), findsWidgets); + }); + + testWidgets('external multi-image carousel still renders with counter', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: externalEmbed( + thumb: _thumb1, + images: const [ + {'thumb': _thumb1}, + {'thumb': _thumb2}, + {'thumb': _thumb3}, + ], + ), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byType(PageView), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); + expect(find.text('example.com/article'), findsOneWidget); + }); + + testWidgets('external Streamable video still renders its play button', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: externalEmbed( + uri: 'https://streamable.com/abc123', + thumb: _thumb1, + embedType: 'video', + provider: 'streamable', + ), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); + expect( + tester + .widgetList(find.byType(CachedNetworkImage)) + .map((image) => image.imageUrl), + contains(_thumb1), + ); + }); + + testWidgets('external link without a thumb renders no media carousel', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: externalEmbed()); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byType(PageView), findsNothing); + expect(find.byType(ExternalLinkBar), findsOneWidget); + }); + + testWidgets('quoted bluesky post still renders its card', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: PostEmbed.fromJson({ + r'$type': 'social.coves.embed.post#view', + 'post': { + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', + 'cid': 'bafyrei123', + }, + }), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byType(BlueskyPostCard), findsOneWidget); + }); + }); + + group('C2 single native image', () { + testWidgets('renders the FULLSIZE url, not the thumb', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect( + imageUrlsIn(tester, _imagesKey), + [_full1], + reason: 'detail view shows fullsize; thumb is the feed-card size', + ); + }); + + testWidgets('uses 16:9 when aspectRatio is absent', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_defaultRatio, 0.01), + ); + }); + + testWidgets('honours an ordinary aspectRatio', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(renderedAspectRatio(tester, _imagesKey), closeTo(1.5, 0.01)); + }); + + testWidgets('keeps a tall 9:21 image, well inside the H1 clamp', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + // 9:21 == 0.4286. The feed card clamps this to 4:5 == 0.8; the + // detail view keeps it, since it is inside [1/3 … 3]. + 'aspectRatio': {'width': 9, 'height': 21}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(renderedAspectRatio(tester, _imagesKey), closeTo(9 / 21, 0.01)); + }); + + testWidgets('keeps a 9:16 portrait unchanged', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 9, 'height': 16}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(renderedAspectRatio(tester, _imagesKey), closeTo(9 / 16, 0.01)); + }); + + testWidgets('H1 clamps a wider-than-3:1 image to 3:1', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + // 32:9 == 3.5556, past the 3:1 ceiling. + 'aspectRatio': {'width': 32, 'height': 9}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_maxDetailRatio, 0.01), + ); + }); + + testWidgets('H1 clamps a hostile 1:1000000 ratio to 1:3', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + // The backend never validates aspectRatio, so this is reachable + // from a federated repo. Unclamped it would lay out a media + // block hundreds of millions of pixels tall. + 'aspectRatio': {'width': 1, 'height': 1000000}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_minDetailRatio, 0.01), + ); + }); + + testWidgets('H1 clamps a hostile 1000000:1 ratio to 3:1', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 1000000, 'height': 1}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_maxDetailRatio, 0.01), + ); + }); + + testWidgets('H1 leaves the clamp boundaries themselves untouched', ( + tester, + ) async { + useDetailSurface(tester); + + for (final ratio in const [ + {'width': 1, 'height': 3}, + {'width': 3, 'height': 1}, + ]) { + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1, 'aspectRatio': ratio}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + final expected = (ratio['width']! / ratio['height']!).toDouble(); + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(expected, 0.01), + reason: 'ratio: $ratio', + ); + } + }); + + testWidgets('shows no page indicator for a single image', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.byKey(_pageIndicatorKey), findsNothing); + }); + + testWidgets('shows no external link bar for a native image', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect( + find.byType(ExternalLinkBar), + findsNothing, + reason: 'a native gallery has no uri, so there is no link to show', + ); + expect(find.byIcon(Icons.open_in_new), findsNothing); + }); + }); + + group('C3 native multi-image carousel', () { + List> threeImages() => const [ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full2}, + {'thumb': _thumb3, 'fullsize': _full3}, + ]; + + testWidgets('renders a PageView of fullsize urls', (tester) async { + useDetailSurface(tester); + final post = makePost(embed: imagesEmbed(threeImages())); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(inBlock(_imagesKey, find.byType(PageView)), findsOneWidget); + expect( + imageUrlsIn(tester, _imagesKey), + contains(_full1), + reason: 'carousel pages use fullsize urls', + ); + expect(imageUrlsIn(tester, _imagesKey), isNot(contains(_thumb1))); + }); + + testWidgets('shows an i/N indicator starting at 1/3', (tester) async { + useDetailSurface(tester); + final post = makePost(embed: imagesEmbed(threeImages())); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_pageIndicatorKey), findsOneWidget); + expect(inBlock(_pageIndicatorKey, find.text('1/3')), findsOneWidget); + }); + + testWidgets('indicator advances to 2/3 after a swipe', (tester) async { + useDetailSurface(tester); + final post = makePost(embed: imagesEmbed(threeImages())); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.drag( + inBlock(_imagesKey, find.byType(PageView)), + const Offset(-400, 0), + ); + await tester.pumpAndSettle(); + + expect(inBlock(_pageIndicatorKey, find.text('2/3')), findsOneWidget); + }); + + testWidgets('shows no external link bar for a native gallery', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: imagesEmbed(threeImages())); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.byType(ExternalLinkBar), findsNothing); + expect(find.byIcon(Icons.open_in_new), findsNothing); + }); + }); + + group('C4 native image alt semantics', () { + testWidgets('exposes alt text on the rendered image', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1, 'alt': 'A red barn at dusk'}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.bySemanticsLabel('A red barn at dusk'), findsOneWidget); + }); + + testWidgets('omits image semantics when alt is absent', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.bySemanticsLabel('A red barn at dusk'), findsNothing); + }); + }); + + group('H3 tappable image semantics', () { + testWidgets('a single native image announces itself as a button', ( + tester, + ) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('View full image')), + containsSemantics(isButton: true, label: 'View full image'), + ); + handle.dispose(); + }); + + testWidgets('a carousel page announces itself as a button', (tester) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full2}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('View full image').first), + containsSemantics(isButton: true, label: 'View full image'), + ); + handle.dispose(); + }); + + testWidgets('a video embed does not claim to open an image', ( + tester, + ) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect(find.bySemanticsLabel('View full image'), findsNothing); + handle.dispose(); + }); + + testWidgets('a text-only post exposes no image button', (tester) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost(text: 'Plain text post'); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Plain text post'), findsOneWidget); + expect(find.bySemanticsLabel('View full image'), findsNothing); + handle.dispose(); + }); + + testWidgets('video alt text reaches the semantics tree', (tester) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: videoEmbed( + thumbnail: _videoThumb, + alt: 'A timelapse of the harbour', + ), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.bySemanticsLabel('A timelapse of the harbour'), findsWidgets); + handle.dispose(); + }); + + testWidgets('H6 the play button label survives a duration badge', ( + tester, + ) async { + useDetailSurface(tester); + final handle = tester.ensureSemantics(); + + // A sibling node inside the block — here the duration badge's text — + // absorbs the wrapper's label unless the Semantics sets + // container: true, explicitChildNodes: true. + final post = makePost( + embed: videoEmbed(thumbnail: _videoThumb, duration: 42), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_durationBadgeKey), findsOneWidget); + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics(isButton: true, label: 'Play video'), + ); + handle.dispose(); + }); + }); + + group('H12 gallery state resets across posts', () { + const nextFull1 = 'https://cdn.test/n1.jpg'; + const nextFull2 = 'https://cdn.test/n2.jpg'; + + FeedViewPost threeImagePost() => makePost( + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full2}, + {'thumb': _thumb3, 'fullsize': _full3}, + ]), + ); + + /// Swipes the gallery to its third page (index 2). + Future swipeToLastPage(WidgetTester tester) async { + for (var i = 0; i < 2; i++) { + await tester.drag( + inBlock(_imagesKey, find.byType(PageView)), + const Offset(-400, 0), + ); + await tester.pumpAndSettle(); + } + } + + testWidgets('a new post starts at page 1 of its own gallery', ( + tester, + ) async { + useDetailSurface(tester); + + await tester.pumpWidget(harness(threeImagePost())); + await tester.pump(); + await swipeToLastPage(tester); + expect(inBlock(_pageIndicatorKey, find.text('3/3')), findsOneWidget); + + // Same widget position and type, so the State is reused — exactly the + // scroll-to-next-post case where a stale index survives. + await tester.pumpWidget( + harness( + makePost( + uri: 'at://did:example/post/999', + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': nextFull1}, + {'thumb': _thumb2, 'fullsize': nextFull2}, + ]), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(inBlock(_pageIndicatorKey, find.text('1/2')), findsOneWidget); + + final pageView = tester.widget( + inBlock(_imagesKey, find.byType(PageView)), + ); + expect( + pageView.controller?.page ?? 0, + closeTo(0, 0.01), + reason: 'the controller must rewind, not just the index counter', + ); + }); + + testWidgets('tapping after a post change opens the new first image', ( + tester, + ) async { + useDetailSurface(tester); + + await tester.pumpWidget(harness(threeImagePost())); + await tester.pump(); + await swipeToLastPage(tester); + + await tester.pumpWidget( + harness( + makePost( + uri: 'at://did:example/post/999', + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': nextFull1}, + {'thumb': _thumb2, 'fullsize': nextFull2}, + ]), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(_imagesKey)); + await tester.pumpAndSettle(); + + expect( + imageUrlsIn(tester, _imageViewerKey), + contains(nextFull1), + reason: 'the viewer must open what the carousel is showing', + ); + }); + + testWidgets('a single-image post shows no stale indicator', (tester) async { + useDetailSurface(tester); + + await tester.pumpWidget(harness(threeImagePost())); + await tester.pump(); + await swipeToLastPage(tester); + + await tester.pumpWidget( + harness( + makePost( + uri: 'at://did:example/post/999', + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': nextFull1}, + ]), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.byKey(_pageIndicatorKey), findsNothing); + expect(imageUrlsIn(tester, _imagesKey), contains(nextFull1)); + }); + }); + + group('H4 viewer placeholder', () { + testWidgets('the viewer shows the thumb while fullsize loads', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.tap(find.byKey(_imagesKey)); + await tester.pumpAndSettle(); + + // While fullsize is still loading, BOTH images are mounted: the + // fullsize itself and the thumb standing in for it. That pair is the + // H4 behaviour, so assert the mounted reality rather than reaching + // into the placeholder builder. + expect( + imageUrlsIn(tester, _imageViewerKey), + containsAll([_full1, _thumb1]), + reason: 'the feed-cached thumb is what makes the viewer feel instant', + ); + }); + }); + + group('C5 fullscreen image viewer', () { + testWidgets('tapping a single image pushes a zoomable viewer', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + observer.reset(); + + await tester.tap(find.byKey(_imagesKey)); + + // The push must be synchronous — assert before pumping a frame. + expect( + observer.pushedPages, + hasLength(1), + reason: 'image tap pushes exactly one MaterialPageRoute', + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(_imageViewerKey), findsOneWidget); + expect( + inBlock(_imageViewerKey, find.byType(InteractiveViewer)), + findsOneWidget, + reason: 'the viewer must support pinch-zoom', + ); + expect(imageUrlsIn(tester, _imageViewerKey), contains(_full1)); + }); + + testWidgets('the viewer can be dismissed back to the post', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.tap(find.byKey(_imagesKey)); + await tester.pumpAndSettle(); + + expect(find.byKey(_imageViewerKey), findsOneWidget); + + final navigator = tester.state(find.byType(Navigator)); + expect( + navigator.canPop(), + isTrue, + reason: 'the viewer sits on a poppable route', + ); + + navigator.pop(); + await tester.pumpAndSettle(); + + expect(find.byKey(_imageViewerKey), findsNothing); + expect(find.byKey(_imagesKey), findsOneWidget); + }); + + testWidgets('tapping a carousel page opens THAT page fullsize', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost( + embed: imagesEmbed(const [ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full2}, + {'thumb': _thumb3, 'fullsize': _full3}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.drag( + inBlock(_imagesKey, find.byType(PageView)), + const Offset(-400, 0), + ); + await tester.pumpAndSettle(); + + await tester.tap(inBlock(_imagesKey, find.byType(PageView))); + await tester.pumpAndSettle(); + + expect(find.byKey(_imageViewerKey), findsOneWidget); + expect( + imageUrlsIn(tester, _imageViewerKey), + contains(_full2), + reason: 'the viewer opens the image the user was looking at', + ); + }); + }); + + group('C6 native video', () { + testWidgets('renders the thumbnail when present', (tester) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect(imageUrlsIn(tester, _videoKey), [_videoThumb]); + }); + + testWidgets('renders a 16:9 placeholder when thumbnail is null', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed()); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect( + inBlock(_videoKey, find.byType(CachedNetworkImage)), + findsNothing, + reason: 'no thumbnail url means nothing to fetch', + ); + expect( + renderedAspectRatio(tester, _videoKey), + closeTo(_defaultRatio, 0.01), + ); + }); + + testWidgets('always shows the play overlay (with thumbnail)', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_playOverlayKey), findsOneWidget); + }); + + testWidgets('always shows the play overlay (no thumbnail)', (tester) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed()); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_playOverlayKey), findsOneWidget); + }); + + testWidgets('shows a m:ss duration badge', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: videoEmbed(thumbnail: _videoThumb, duration: 42), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_durationBadgeKey), findsOneWidget); + expect(inBlock(_durationBadgeKey, find.text('0:42')), findsOneWidget); + }); + + testWidgets('shows an h:mm:ss badge for long videos', (tester) async { + useDetailSurface(tester); + final post = makePost( + embed: videoEmbed(thumbnail: _videoThumb, duration: 3723), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + inBlock(_durationBadgeKey, find.text('1:02:03')), + findsOneWidget, + reason: 'must reuse formatVideoDuration, not a naive m:ss formatter', + ); + }); + + testWidgets('omits the duration badge when duration is null', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect(find.byKey(_durationBadgeKey), findsNothing); + }); + + testWidgets('tapping the video pushes the fullscreen player', ( + tester, + ) async { + useDetailSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + observer.reset(); + + await tester.tap(find.byKey(_videoKey)); + // Deliberately NOT pumping: mounting FullscreenVideoPlayer hits the + // video_player platform channel, which throws UnimplementedError + // under flutter_test (an Error, so the widget's own catch misses it). + + final videoRoutes = observer.pushedPages; + expect(videoRoutes, hasLength(1)); + + final page = videoRoutes.single.builder( + tester.element(find.byKey(_videoKey)), + ); + expect(page, isA()); + expect((page as FullscreenVideoPlayer).videoUrl, _videoUrl); + }); + }); + + group('C7 no renderable media', () { + testWidgets('an unknown embed renders text only', (tester) async { + useDetailSurface(tester); + final post = makePost( + text: 'Just body text', + embed: PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'payload': {'a': 1}, + }), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Test Post Title'), findsOneWidget); + expect(find.text('Just body text'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + expect(find.byKey(_videoKey), findsNothing); + expect(find.byKey(_pageIndicatorKey), findsNothing); + expect(find.byKey(_durationBadgeKey), findsNothing); + }); + + testWidgets('an unhydrated images record renders no media', (tester) async { + useDetailSurface(tester); + // Record shape (no #view) carrying blob refs: hydration failed + // upstream, so the client must render nothing rather than build a + // getBlob url itself. + final post = makePost( + text: 'Hydration failed upstream', + embed: PostEmbed.fromJson({ + r'$type': 'social.coves.embed.images', + 'images': [ + { + 'image': { + r'$type': 'blob', + 'ref': {r'$link': 'bafyimage1'}, + 'mimeType': 'image/jpeg', + 'size': 123456, + }, + }, + ], + }), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Hydration failed upstream'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + expect(find.byKey(_pageIndicatorKey), findsNothing); + }); + + testWidgets('a post with no embed renders no media', (tester) async { + useDetailSurface(tester); + final post = makePost(text: 'Plain text post'); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Plain text post'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + expect(find.byKey(_videoKey), findsNothing); + }); + }); +} diff --git a/test/widgets/fullscreen_video_player_test.dart b/test/widgets/fullscreen_video_player_test.dart new file mode 100644 index 0000000..cff86be --- /dev/null +++ b/test/widgets/fullscreen_video_player_test.dart @@ -0,0 +1,147 @@ +import 'package:coves_flutter/widgets/fullscreen_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Root of the failure UI shown when the player cannot initialise. +const _errorKey = Key('fullscreen-video-error'); + +const _videoUrl = 'https://cdn.test/video.mp4'; + +void main() { + // Under flutter_test the video_player platform interface is never + // registered, so `VideoPlayerController.initialize()` throws + // `UnimplementedError: init() has not been implemented`. That is an + // Error, not an Exception — which is precisely the H5 bug: the widget's + // `on Exception catch` misses it, `_isInitializing` stays true, and the + // user watches a spinner forever. These tests lean on that same failure + // to drive the error state. + // + // Note the deliberate absence of pumpAndSettle: while the bug is live the + // spinner animates forever and pumpAndSettle only reports a timeout, + // which says nothing about what is actually wrong. Bounded pumps give the + // real assertion failure both before and after the fix. + Future settleInit(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + Future pumpPlayer(WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: FullscreenVideoPlayer(videoUrl: _videoUrl)), + ); + await settleInit(tester); + } + + group('H5 FullscreenVideoPlayer initialisation failure', () { + testWidgets('does not let the failure escape as an uncaught error', ( + tester, + ) async { + await pumpPlayer(tester); + + expect( + tester.takeException(), + isNull, + reason: 'the catch must cover Errors, not just Exceptions', + ); + }); + + testWidgets('stops spinning and shows an error state', (tester) async { + await pumpPlayer(tester); + + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: 'an infinite spinner is the failure mode H5 removes', + ); + expect(find.byKey(_errorKey), findsOneWidget); + }); + + testWidgets('the error state explains itself with an icon and text', ( + tester, + ) async { + await pumpPlayer(tester); + + final errorBlock = find.byKey(_errorKey); + expect( + find.descendant(of: errorBlock, matching: find.byType(Icon)), + findsWidgets, + ); + + final messages = tester + .widgetList( + find.descendant(of: errorBlock, matching: find.byType(Text)), + ) + .map((text) => text.data) + .whereType() + .where((data) => data.trim().isNotEmpty); + expect( + messages, + isNotEmpty, + reason: 'an icon with no words leaves the user guessing', + ); + }); + + testWidgets('shows no scrubber when initialisation failed', (tester) async { + await pumpPlayer(tester); + + expect(find.byKey(_errorKey), findsOneWidget); + expect( + find.byType(Slider), + findsNothing, + reason: 'scrubbing a video that never loaded is meaningless', + ); + }); + + testWidgets('offers a labelled close affordance that pops the route', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: + (context) => ElevatedButton( + onPressed: + () => Navigator.of(context).push( + MaterialPageRoute( + builder: + (_) => const FullscreenVideoPlayer( + videoUrl: _videoUrl, + ), + fullscreenDialog: true, + ), + ), + child: const Text('OPEN'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('OPEN')); + await settleInit(tester); + + expect(find.byKey(_errorKey), findsOneWidget); + + final closeButton = find.bySemanticsLabel('Close'); + expect( + closeButton, + findsWidgets, + reason: 'the error state needs a labelled way out', + ); + expect( + tester.getSemantics(closeButton.first), + containsSemantics(isButton: true, label: 'Close'), + ); + + await tester.tap(closeButton.first); + await settleInit(tester); + + expect(find.byKey(_errorKey), findsNothing); + expect(find.text('OPEN'), findsOneWidget); + handle.dispose(); + }); + }); +} diff --git a/test/widgets/post_card_media_test.dart b/test/widgets/post_card_media_test.dart new file mode 100644 index 0000000..cf4e68c --- /dev/null +++ b/test/widgets/post_card_media_test.dart @@ -0,0 +1,842 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/widgets/fullscreen_video_player.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'; + +const _imagesKey = Key('post-images-embed'); +const _countBadgeKey = Key('post-images-count-badge'); +const _videoKey = Key('post-video-embed'); +const _playOverlayKey = Key('post-video-play-overlay'); +const _durationBadgeKey = Key('post-video-duration-badge'); + +const _thumb1 = 'https://cdn.test/t1.jpg'; +const _thumb2 = 'https://cdn.test/t2.jpg'; +const _thumb3 = 'https://cdn.test/t3.jpg'; +const _full1 = 'https://cdn.test/f1.jpg'; +const _videoUrl = 'https://cdn.test/video.mp4'; +const _videoThumb = 'https://cdn.test/video-thumb.jpg'; + +const _detailMarker = 'DETAIL SCREEN'; + +/// 16:9 and 4:5 as width/height doubles — the clamp bounds from B1. +const _widestRatio = 16 / 9; +const _tallestRatio = 4 / 5; + +/// Records routes pushed onto the root navigator so a tap handler's +/// `Navigator.push` can be inspected without mounting the pushed page. +class _RecordingObserver extends NavigatorObserver { + final List> pushed = >[]; + + @override + void didPush(Route route, Route? previousRoute) { + pushed.add(route); + super.didPush(route, previousRoute); + } +} + +void main() { + late FakeAuthProvider auth; + late _RecordingObserver observer; + + setUp(() { + auth = FakeAuthProvider(); + observer = _RecordingObserver(); + }); + + /// Gives the card a phone-sized surface. The default 800x600 test view is + /// too short for a full-width 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, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Widget harness(FeedViewPost post, {bool disableNavigation = false}) { + final router = GoRouter( + observers: [observer], + routes: [ + GoRoute( + path: '/', + builder: + (context, state) => Scaffold( + body: PostCard( + post: post, + disableNavigation: disableNavigation, + ), + ), + ), + GoRoute( + path: '/post/:uri', + builder: + (context, state) => const Scaffold(body: Text(_detailMarker)), + ), + ], + ); + addTearDown(router.dispose); + + return MultiProvider( + providers: postCardProviders(auth: auth), + child: MaterialApp.router(routerConfig: router), + ); + } + + FeedViewPost makePost({ + PostEmbed? embed, + String? title = 'Test Post Title', + String text = '', + }) { + 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: PostRecord(title: title, content: text), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: embed, + ), + ); + } + + /// Builds an `images#view` embed through the real model parser, so these + /// tests double as an integration check of the model -> widget flow. + PostEmbed imagesEmbed(List> images) { + return PostEmbed.fromJson({ + r'$type': 'social.coves.embed.images#view', + 'images': images, + }); + } + + PostEmbed videoEmbed({ + String video = _videoUrl, + String? thumbnail, + String? alt, + int? duration, + }) { + return PostEmbed.fromJson({ + r'$type': 'social.coves.embed.video#view', + 'video': video, + if (thumbnail != null) 'thumbnail': thumbnail, + if (alt != null) 'alt': alt, + if (duration != null) 'duration': duration, + }); + } + + Finder inBlock(Key blockKey, Finder matching) { + return find.descendant( + of: find.byKey(blockKey), + matching: matching, + matchRoot: true, + ); + } + + /// The aspect ratio the media block actually renders at. + double renderedAspectRatio(WidgetTester tester, Key blockKey) { + final finder = inBlock(blockKey, find.byType(AspectRatio)); + expect( + finder, + findsWidgets, + reason: 'media block $blockKey should size itself with an AspectRatio', + ); + return tester.widget(finder.first).aspectRatio; + } + + group('PostCard images embed', () { + testWidgets('B1 renders the first image thumb full width', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + + final imageFinder = inBlock(_imagesKey, find.byType(CachedNetworkImage)); + expect(imageFinder, findsOneWidget); + expect( + tester.widget(imageFinder).imageUrl, + _thumb1, + reason: 'only the FIRST image renders on the feed card', + ); + }); + + testWidgets('B1 uses 16:9 when aspectRatio is absent', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_widestRatio, 0.01), + ); + }); + + testWidgets('B1 honours an in-range aspectRatio unchanged', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + 'aspectRatio': {'width': 3, 'height': 2}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(renderedAspectRatio(tester, _imagesKey), closeTo(1.5, 0.01)); + }); + + testWidgets('B1 clamps an extremely tall image to 4:5', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + // 9:21 == 0.43, far taller than the 4:5 == 0.8 floor + 'aspectRatio': {'width': 9, 'height': 21}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_tallestRatio, 0.01), + ); + }); + + testWidgets('B1 clamps an extremely wide image to 16:9', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + { + 'thumb': _thumb1, + 'fullsize': _full1, + // 32:9 == 3.56, far wider than the 16:9 == 1.78 ceiling + 'aspectRatio': {'width': 32, 'height': 9}, + }, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + renderedAspectRatio(tester, _imagesKey), + closeTo(_widestRatio, 0.01), + ); + }); + + testWidgets('B1 exposes alt text as image semantics', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1, 'alt': 'A red barn at dusk'}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + find.bySemanticsLabel('A red barn at dusk'), + findsOneWidget, + reason: 'alt text must reach the semantics tree for screen readers', + ); + }); + + testWidgets('B1 omits image semantics when alt is absent', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.bySemanticsLabel('A red barn at dusk'), findsNothing); + }); + + testWidgets('B2 shows a 1/N badge for a multi-image embed', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full1}, + {'thumb': _thumb3, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_countBadgeKey), findsOneWidget); + expect(inBlock(_countBadgeKey, find.text('1/3')), findsOneWidget); + }); + + testWidgets('B2 omits the badge for a single image', (tester) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect(find.byKey(_countBadgeKey), findsNothing); + }); + + testWidgets('B3 tapping the images block opens post detail', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.tap(find.byKey(_imagesKey)); + await tester.pumpAndSettle(); + + expect(find.text(_detailMarker), findsOneWidget); + }); + + testWidgets('B3 disableNavigation suppresses the images tap', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post, disableNavigation: true)); + await tester.pump(); + + await tester.tap(find.byKey(_imagesKey)); + await tester.pumpAndSettle(); + + expect(find.text(_detailMarker), findsNothing); + expect(find.byKey(_imagesKey), findsOneWidget); + }); + }); + + group('H3 tappable image semantics', () { + testWidgets('the images block announces itself as a button', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Open post')), + containsSemantics(isButton: true, label: 'Open post'), + ); + handle.dispose(); + }); + + testWidgets('the button semantics survive a multi-image embed', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + {'thumb': _thumb2, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Open post')), + containsSemantics(isButton: true, label: 'Open post'), + ); + handle.dispose(); + }); + + testWidgets('a text-only post exposes no image button', (tester) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost(text: 'Plain text post'); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Plain text post'), findsOneWidget); + expect(find.bySemanticsLabel('Open post'), findsNothing); + handle.dispose(); + }); + + testWidgets('H11 a non-tappable images block announces no button', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post, disableNavigation: true)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + expect( + find.bySemanticsLabel('Open post'), + findsNothing, + reason: 'a dead actionable node is worse than no node at all', + ); + handle.dispose(); + }); + + testWidgets('H11 the tappable block still announces a button', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Open post')), + containsSemantics(isButton: true, label: 'Open post'), + ); + handle.dispose(); + }); + + testWidgets('video alt text reaches the semantics tree', (tester) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + final post = makePost( + embed: videoEmbed( + thumbnail: _videoThumb, + alt: 'A timelapse of the harbour', + ), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.bySemanticsLabel('A timelapse of the harbour'), findsWidgets); + handle.dispose(); + }); + + testWidgets('H6 the play button label survives a duration badge', ( + tester, + ) async { + useMediaSizedSurface(tester); + final handle = tester.ensureSemantics(); + + // A sibling node inside the block — here the duration badge's text — + // absorbs the wrapper's label unless the Semantics sets + // container: true, explicitChildNodes: true. + final post = makePost( + embed: videoEmbed(thumbnail: _videoThumb, duration: 42), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_durationBadgeKey), findsOneWidget); + expect( + tester.getSemantics(find.bySemanticsLabel('Play video')), + containsSemantics(isButton: true, label: 'Play video'), + ); + handle.dispose(); + }); + }); + + group('PostCard video embed', () { + testWidgets('B4 renders the thumbnail when present', (tester) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + + final imageFinder = inBlock(_videoKey, find.byType(CachedNetworkImage)); + expect(imageFinder, findsOneWidget); + expect( + tester.widget(imageFinder).imageUrl, + _videoThumb, + ); + }); + + testWidgets('B4 renders a 16:9 placeholder when thumbnail is null', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed()); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect( + inBlock(_videoKey, find.byType(CachedNetworkImage)), + findsNothing, + reason: 'no thumbnail URL means nothing to fetch', + ); + expect( + renderedAspectRatio(tester, _videoKey), + closeTo(_widestRatio, 0.01), + ); + }); + + testWidgets('B4 always shows the play overlay (with thumbnail)', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_playOverlayKey), findsOneWidget); + }); + + testWidgets('B4 always shows the play overlay (no thumbnail)', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed()); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_playOverlayKey), findsOneWidget); + }); + + testWidgets('B5 shows the duration badge when duration is set', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + embed: videoEmbed(thumbnail: _videoThumb, duration: 42), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_durationBadgeKey), findsOneWidget); + expect(inBlock(_durationBadgeKey, find.text('0:42')), findsOneWidget); + }); + + testWidgets('B5 omits the duration badge when duration is null', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect(find.byKey(_durationBadgeKey), findsNothing); + }); + + testWidgets('B6 tapping the video pushes the fullscreen player', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + await tester.tap(find.byKey(_videoKey)); + // Deliberately NOT pumping: mounting FullscreenVideoPlayer would hit + // the video_player platform channel, which throws UnimplementedError + // under flutter_test. Inspecting the pushed route instead asserts the + // same contract without building the page. + + final videoRoutes = + observer.pushed.whereType>().toList(); + expect( + videoRoutes, + hasLength(1), + reason: 'video tap should push exactly one page route', + ); + + final page = videoRoutes.single.builder( + tester.element(find.byKey(_videoKey)), + ); + expect(page, isA()); + expect((page as FullscreenVideoPlayer).videoUrl, _videoUrl); + }); + + testWidgets('B6 video tap still plays when navigation is disabled', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost(embed: videoEmbed(thumbnail: _videoThumb)); + + await tester.pumpWidget(harness(post, disableNavigation: true)); + await tester.pump(); + + await tester.tap(find.byKey(_videoKey)); + + final videoRoutes = + observer.pushed.whereType>().toList(); + expect( + videoRoutes, + hasLength(1), + reason: 'media plays in place regardless of disableNavigation', + ); + + final page = videoRoutes.single.builder( + tester.element(find.byKey(_videoKey)), + ); + expect((page as FullscreenVideoPlayer).videoUrl, _videoUrl); + }); + }); + + group('PostCard media layout and fallbacks', () { + testWidgets('B7 renders images for a post with no title and no text', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + title: null, + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_imagesKey), findsOneWidget); + }); + + testWidgets('B7 renders video for a post with no title and no text', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + title: null, + embed: videoEmbed(thumbnail: _videoThumb), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.byKey(_videoKey), findsOneWidget); + expect(find.byKey(_playOverlayKey), findsOneWidget); + }); + + testWidgets('B9 media sits between the title and the post text', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + text: 'Body text below the media', + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + final titleBottom = tester.getBottomLeft(find.text('Test Post Title')).dy; + final mediaTop = tester.getTopLeft(find.byKey(_imagesKey)).dy; + final mediaBottom = tester.getBottomLeft(find.byKey(_imagesKey)).dy; + final textTop = + tester.getTopLeft(find.text('Body text below the media')).dy; + + expect( + mediaTop - titleBottom, + greaterThanOrEqualTo(12.0), + reason: 'B9: the title->media gap must survive the new variants', + ); + expect( + textTop, + greaterThan(mediaBottom), + reason: 'media renders above the post text', + ); + }); + + testWidgets('B9 the title gap holds when media is the only content', ( + tester, + ) async { + useMediaSizedSurface(tester); + // No body text and no external embed: the media clause of + // hasContentBelowTitle is the only thing that can open the gap. + final post = makePost( + embed: imagesEmbed([ + {'thumb': _thumb1, 'fullsize': _full1}, + ]), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + final titleBottom = tester.getBottomLeft(find.text('Test Post Title')).dy; + final mediaTop = tester.getTopLeft(find.byKey(_imagesKey)).dy; + + expect(mediaTop - titleBottom, greaterThanOrEqualTo(12.0)); + }); + + testWidgets('B8 an unknown embed renders as a text-only post', ( + tester, + ) async { + useMediaSizedSurface(tester); + final post = makePost( + text: 'Just body text', + embed: PostEmbed.fromJson({ + r'$type': 'social.coves.embed.somethingNew', + 'payload': {'a': 1}, + }), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Test Post Title'), findsOneWidget); + expect(find.text('Just body text'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + expect(find.byKey(_videoKey), findsNothing); + expect(find.byKey(_playOverlayKey), findsNothing); + expect(find.byKey(_durationBadgeKey), findsNothing); + expect(find.byKey(_countBadgeKey), findsNothing); + }); + + testWidgets('B8 an unhydrated images record renders no media', ( + tester, + ) async { + useMediaSizedSurface(tester); + // Record shape (no #view) with blob refs — hydration failed upstream, + // so the card must show no media rather than build a blob URL itself. + final post = makePost( + text: 'Hydration failed upstream', + embed: PostEmbed.fromJson({ + r'$type': 'social.coves.embed.images', + 'images': [ + { + 'image': { + r'$type': 'blob', + 'ref': {r'$link': 'bafyimage1'}, + 'mimeType': 'image/jpeg', + 'size': 123456, + }, + }, + ], + }), + ); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Hydration failed upstream'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + expect(find.byKey(_countBadgeKey), findsNothing); + }); + + testWidgets('B8 a post with no embed renders no media', (tester) async { + useMediaSizedSurface(tester); + final post = makePost(text: 'Plain text post'); + + await tester.pumpWidget(harness(post)); + await tester.pump(); + + expect(find.text('Plain text post'), findsOneWidget); + expect(find.byKey(_imagesKey), findsNothing); + 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 a78babe..038a0cc 100644 --- a/test/widgets/post_card_test.dart +++ b/test/widgets/post_card_test.dart @@ -4,285 +4,324 @@ 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/mock_providers.dart'; +import '../test_helpers/fake_providers.dart'; void main() { - late MockAuthProvider mockAuthProvider; - late MockVoteProvider mockVoteProvider; + late FakeAuthProvider auth; setUp(() { - mockAuthProvider = MockAuthProvider(); - mockVoteProvider = MockVoteProvider(); + auth = FakeAuthProvider(); }); Widget createTestWidget( FeedViewPost post, { StreamableService? streamableService, }) { - return MultiProvider( - providers: [ - // ignore: argument_type_not_assignable - ChangeNotifierProvider.value(value: mockAuthProvider), - // ignore: argument_type_not_assignable - ChangeNotifierProvider.value(value: mockVoteProvider), - Provider.value( - value: streamableService ?? StreamableService(), + // PostCard's subtree navigates with go_router (TappableAuthor, + // TappableCommunity, _navigateToDetail), so it needs a router rather + // than a bare MaterialApp. + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: PostCard(post: post)), + ), + GoRoute( + path: '/post/:uri', + builder: + (context, state) => const Scaffold(body: Text('DETAIL SCREEN')), ), ], - child: MaterialApp(home: Scaffold(body: PostCard(post: post))), + ); + addTearDown(router.dispose); + + return MultiProvider( + providers: postCardProviders( + auth: auth, + streamableService: streamableService, + ), + child: MaterialApp.router(routerConfig: router), ); } - group( - 'PostCard', - skip: 'Provider type compatibility issues - needs mock refactoring', - () { - testWidgets('renders all basic components', (tester) async { - 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: PostRecord( - content: 'Test post content', - title: 'Test Post Title', - ), - stats: PostStats( - upvotes: 10, - downvotes: 2, - score: 8, - commentCount: 5, - ), + group('PostCard', () { + testWidgets('renders all basic components', (tester) async { + 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', ), - ); - - await tester.pumpWidget(createTestWidget(post)); - - // Verify title is displayed - expect(find.text('Test Post Title'), findsOneWidget); - - // Verify community name is displayed - expect(find.text('c/test-community'), findsOneWidget); - - // Verify author handle is displayed - expect(find.text('@author.test'), findsOneWidget); - - // Verify text content is displayed - expect(find.text('Test post content'), findsOneWidget); - - // Verify stats are displayed - expect(find.text('8'), findsOneWidget); // score - expect(find.text('5'), findsOneWidget); // comment count - }); - - testWidgets('displays community avatar when available', (tester) async { - 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', - avatar: 'https://example.com/avatar.jpg', - ), - createdAt: DateTime(2024), - indexedAt: DateTime(2024), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + record: const PostRecord( + content: 'Test post content', + title: 'Test Post Title', ), - ); - - await tester.pumpWidget(createTestWidget(post)); - await tester.pumpAndSettle(); - - // Avatar image should be present - expect(find.byType(Image), findsWidgets); - }); - - testWidgets('shows fallback avatar when no avatar URL', (tester) async { - 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: 'TestCommunity', - ), - createdAt: DateTime(2024), - indexedAt: DateTime(2024), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), + stats: PostStats( + upvotes: 10, + downvotes: 2, + score: 8, + commentCount: 5, ), - ); + ), + ); - await tester.pumpWidget(createTestWidget(post)); + await tester.pumpWidget(createTestWidget(post)); + + // Verify title is displayed + expect(find.text('Test Post Title'), findsOneWidget); + + // Verify community name is displayed. With no atProto handle set, + // the card falls back to the bare community name; a handle would + // render as `!name@instance` via Text.rich instead. + expect(find.text('test-community'), findsOneWidget); + + // Verify author handle is displayed + expect(find.text('@author.test'), findsOneWidget); + + // Verify text content is displayed + expect(find.text('Test post content'), findsOneWidget); + + // Verify stats are displayed + expect(find.text('8'), findsOneWidget); // score + expect(find.text('5'), findsOneWidget); // comment count + }); + + testWidgets('displays community avatar when available', (tester) async { + 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', + avatar: 'https://example.com/avatar.jpg', + ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); - // Verify fallback shows first letter - expect(find.text('T'), findsOneWidget); - }); + await tester.pumpWidget(createTestWidget(post)); + await tester.pumpAndSettle(); + + // Avatar image should be present + expect(find.byType(Image), findsWidgets); + }); + + testWidgets('shows fallback avatar when no avatar URL', (tester) async { + 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: 'TestCommunity', + ), + createdAt: DateTime(2024), + indexedAt: DateTime(2024), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); - testWidgets('displays external link bar when embed present', ( - tester, - ) async { - 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), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), - embed: PostEmbed( - type: 'social.coves.embed.external', - external: ExternalEmbed( - uri: 'https://example.com/article', - domain: 'example.com', - title: 'Example Article', - ), - data: const {}, + await tester.pumpWidget(createTestWidget(post)); + + // Verify fallback shows first letter + expect(find.text('T'), findsOneWidget); + }); + + testWidgets('displays external link bar when embed present', ( + tester, + ) async { + 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), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: ExternalPostEmbed( + type: 'social.coves.embed.external', + external: ExternalEmbed( + uri: 'https://example.com/article', + domain: 'example.com', + title: 'Example Article', ), + data: const {}, ), - ); - - await tester.pumpWidget(createTestWidget(post)); - - // Verify external link bar is present - expect(find.text('example.com'), findsOneWidget); - expect(find.byIcon(Icons.open_in_new), findsOneWidget); - }); + ), + ); - testWidgets('displays embed image when available', (tester) async { - 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), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), - embed: PostEmbed( - type: 'social.coves.embed.external', - external: ExternalEmbed( - uri: 'https://example.com/article', - thumb: 'https://example.com/thumb.jpg', - ), - data: const {}, + await tester.pumpWidget(createTestWidget(post)); + + // Verify external link bar is present + expect(find.text('example.com'), findsOneWidget); + expect(find.byIcon(Icons.open_in_new), findsOneWidget); + }); + + testWidgets('displays embed image when available', (tester) async { + 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), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: ExternalPostEmbed( + type: 'social.coves.embed.external', + external: ExternalEmbed( + uri: 'https://example.com/article', + thumb: 'https://example.com/thumb.jpg', ), + data: const {}, ), - ); + ), + ); - await tester.pumpWidget(createTestWidget(post)); - await tester.pump(); + await tester.pumpWidget(createTestWidget(post)); + await tester.pump(); + + // Embed image should be loading/present + expect(find.byType(Image), findsWidgets); + }); + + testWidgets('renders without title', (tester) async { + 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: 'Just body text'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); - // Embed image should be loading/present - expect(find.byType(Image), findsWidgets); - }); + await tester.pumpWidget(createTestWidget(post)); + + // Should render without errors + expect(find.text('Just body text'), findsOneWidget); + expect(find.text('test-community'), findsOneWidget); + }); + + testWidgets('has action buttons', (tester) async { + 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), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); - testWidgets('renders without title', (tester) async { - 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: PostRecord(content: 'Just body text'), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, + await tester.pumpWidget(createTestWidget(post)); + + // Verify action buttons are present + expect(find.byIcon(Icons.more_horiz), findsOneWidget); // menu + // Share, comment, and heart icons are custom widgets, verify by count + expect(find.byType(InkWell), findsWidgets); + }); + + testWidgets('displays play button overlay for Streamable videos', ( + tester, + ) async { + 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), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: ExternalPostEmbed( + type: 'social.coves.embed.external', + external: ExternalEmbed( + uri: 'https://streamable.com/abc123', + thumb: 'https://example.com/thumb.jpg', + embedType: 'video', + provider: 'streamable', ), + data: const {}, ), - ); - - await tester.pumpWidget(createTestWidget(post)); - - // Should render without errors - expect(find.text('Just body text'), findsOneWidget); - expect(find.text('c/test-community'), findsOneWidget); - }); + ), + ); - testWidgets('has action buttons', (tester) async { - 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), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), + await tester.pumpWidget(createTestWidget(post)); + await tester.pump(); + + // Verify play button is displayed + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets( + 'shows loading indicator when fetching video URL for Streamable', + (tester) async { + final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com')); + final dioAdapter = DioAdapter(dio: dio); + final streamableService = StreamableService(dio: dio); + + // The service requests the absolute URL, so the mock must be + // registered against the full path, not a baseUrl-relative one. + // + // The reply carries no `files` entry, so getVideoUrl resolves to + // null and the widget shows a snackbar instead of pushing + // FullscreenVideoPlayer — mounting that player would hit the + // video_player platform channel, which throws UnimplementedError + // under flutter_test. The delay is what makes the loading state + // observable before the request settles. + dioAdapter.onGet( + 'https://api.streamable.com/videos/abc123', + (server) => server.reply( + 200, + {}, + delay: const Duration(milliseconds: 500), ), ); - await tester.pumpWidget(createTestWidget(post)); - - // Verify action buttons are present - expect(find.byIcon(Icons.more_horiz), findsOneWidget); // menu - // Share, comment, and heart icons are custom widgets, verify by count - expect(find.byType(InkWell), findsWidgets); - }); - - testWidgets('displays play button overlay for Streamable videos', ( - tester, - ) async { final post = FeedViewPost( post: PostView( uri: 'at://did:example/post/123', @@ -301,7 +340,7 @@ void main() { score: 0, commentCount: 0, ), - embed: PostEmbed( + embed: ExternalPostEmbed( type: 'social.coves.embed.external', external: ExternalEmbed( uri: 'https://streamable.com/abc123', @@ -314,113 +353,60 @@ void main() { ), ); - await tester.pumpWidget(createTestWidget(post)); + await tester.pumpWidget( + createTestWidget(post, streamableService: streamableService), + ); await tester.pump(); - // Verify play button is displayed - expect(find.byIcon(Icons.play_arrow), findsOneWidget); - }); - - testWidgets( - 'shows loading indicator when fetching video URL for Streamable', - (tester) async { - final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com')); - final dioAdapter = DioAdapter(dio: dio); - final streamableService = StreamableService(dio: dio); - - // Delay the response to test loading state - dioAdapter.onGet( - '/videos/abc123', - (server) => server.reply(200, { - 'files': { - 'mp4': {'url': '//cdn.streamable.com/video.mp4'}, - }, - }, delay: const Duration(milliseconds: 500)), - ); - - 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), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), - embed: PostEmbed( - type: 'social.coves.embed.external', - external: ExternalEmbed( - uri: 'https://streamable.com/abc123', - thumb: 'https://example.com/thumb.jpg', - embedType: 'video', - provider: 'streamable', - ), - data: const {}, - ), - ), - ); + // Tap the play button + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); - await tester.pumpWidget( - createTestWidget(post, streamableService: streamableService), - ); - await tester.pump(); + // Verify loading indicator is displayed + expect(find.byType(CircularProgressIndicator), findsOneWidget); - // Tap the play button - await tester.tap(find.byIcon(Icons.play_arrow)); - await tester.pump(); + // Drain the request delay and the resulting snackbar so no timer + // is left pending when the test tears down. + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); - // Verify loading indicator is displayed - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }, - ); + expect(find.byType(CircularProgressIndicator), findsNothing); + }, + ); - testWidgets('does not show play button for non-video embeds', ( - tester, - ) async { - 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), - stats: PostStats( - upvotes: 0, - downvotes: 0, - score: 0, - commentCount: 0, - ), - embed: PostEmbed( - type: 'social.coves.embed.external', - external: ExternalEmbed( - uri: 'https://example.com/article', - thumb: 'https://example.com/thumb.jpg', - ), - data: const {}, + testWidgets('does not show play button for non-video embeds', ( + tester, + ) async { + 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), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + embed: ExternalPostEmbed( + type: 'social.coves.embed.external', + external: ExternalEmbed( + uri: 'https://example.com/article', + thumb: 'https://example.com/thumb.jpg', ), + data: const {}, ), - ); + ), + ); - await tester.pumpWidget(createTestWidget(post)); - await tester.pump(); + await tester.pumpWidget(createTestWidget(post)); + await tester.pump(); - // Verify play button is NOT displayed - expect(find.byIcon(Icons.play_arrow), findsNothing); - }); - }, - ); + // Verify play button is NOT displayed + expect(find.byIcon(Icons.play_arrow), findsNothing); + }); + }); } diff --git a/test/widgets/post_detail_loader_test.dart b/test/widgets/post_detail_loader_test.dart index 29bd8bf..c57c525 100644 --- a/test/widgets/post_detail_loader_test.dart +++ b/test/widgets/post_detail_loader_test.dart @@ -159,12 +159,11 @@ void main() { expect(find.byType(PostDetailScreen), findsOneWidget); expect(find.byType(NotFoundError), findsNothing); - // Unmount explicitly: PostDetailScreen.dispose calls context.read, - // which throws a debug-only FlutterError during tree finalization - // (pre-existing issue in post_detail_screen.dart, out of scope here). - // Absorb that one known error so it doesn't fail the test teardown. + // Unmount explicitly and assert clean disposal: PostDetailScreen.dispose + // used to context.read during tree finalization and throw a FlutterError; + // it now removes its auth listener via a stored reference. await tester.pumpWidget(const MaterialApp(home: Scaffold())); - expect(tester.takeException(), isA()); + expect(tester.takeException(), isNull); await tester.pumpAndSettle(); });