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

feat: composer ReplyContextCard and QuotePostCard widgets

Changed files
+472 -12
doc
lib
src
features
composer
test
src
features
+12 -12
doc/roadmap.txt
··· 119 119 - [x] Update UploadProgressTile with retry 120 120 121 121 Phase 4 - Reply and Quote Posts (MEDIUM): 122 - - [ ] Create ReplyContextCard widget: 123 - - [ ] Display parent post author 124 - - [ ] Display parent post text (truncated) 125 - - [ ] Show thread indicator line 126 - - [ ] Compact card layout 127 - - [ ] Create QuotePostCard widget: 128 - - [ ] Display quoted post author 129 - - [ ] Display quoted post text 130 - - [ ] Show quoted post media (if any) 131 - - [ ] Card border styling 122 + - [x] Create ReplyContextCard widget: 123 + - [x] Display parent post author 124 + - [x] Display parent post text (truncated) 125 + - [x] Show thread indicator line 126 + - [x] Compact card layout 127 + - [x] Create QuotePostCard widget: 128 + - [x] Display quoted post author 129 + - [x] Display quoted post text 130 + - [x] Show quoted post media (if any) 131 + - [x] Card border styling 132 132 - [ ] Composer reply support: 133 133 - [ ] Accept replyTo parameter (post URI) 134 134 - [ ] Fetch parent post data ··· 196 196 - [ ] ComposerScreen interactions 197 197 - [ ] DraftListScreen rendering 198 198 - [x] AltTextEditorSheet input 199 - - [ ] ReplyContextCard display 200 - - [ ] QuotePostCard display 199 + - [x] ReplyContextCard display 200 + - [x] QuotePostCard display 201 201 - [ ] Character counter states 202 202 - [ ] Maintain existing widget tests 203 203
+95
lib/src/features/composer/presentation/widgets/quote_post_card.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/src/core/domain/post.dart'; 3 + import 'package:lazurite/src/core/widgets/avatar.dart'; 4 + 5 + /// Displays a quoted post preview in the composer. 6 + /// 7 + /// Shows the quoted post author, text, and media indicator in a bordered card. 8 + /// Mirrors the styling from feed quoted post embeds. 9 + class QuotePostCard extends StatelessWidget { 10 + const QuotePostCard({required this.author, required this.text, this.imageCount = 0, super.key}); 11 + 12 + /// The author of the quoted post. 13 + final Author author; 14 + 15 + /// The text content of the quoted post. 16 + final String text; 17 + 18 + /// Number of images attached to the quoted post. 19 + final int imageCount; 20 + 21 + @override 22 + Widget build(BuildContext context) { 23 + final theme = Theme.of(context); 24 + final colorScheme = theme.colorScheme; 25 + 26 + return Container( 27 + decoration: BoxDecoration( 28 + color: colorScheme.surfaceContainer, 29 + border: Border.all(color: colorScheme.outlineVariant), 30 + borderRadius: BorderRadius.circular(12), 31 + ), 32 + padding: const EdgeInsets.all(12), 33 + child: Column( 34 + crossAxisAlignment: CrossAxisAlignment.start, 35 + mainAxisSize: MainAxisSize.min, 36 + children: [ 37 + Row( 38 + children: [ 39 + Avatar(imageUrl: author.avatar, radius: 12), 40 + const SizedBox(width: 8), 41 + Expanded( 42 + child: RichText( 43 + maxLines: 1, 44 + overflow: TextOverflow.ellipsis, 45 + text: TextSpan( 46 + children: [ 47 + if (author.displayName != null && author.displayName!.isNotEmpty) 48 + TextSpan( 49 + text: author.displayName, 50 + style: theme.textTheme.bodyMedium?.copyWith( 51 + fontWeight: FontWeight.w600, 52 + color: colorScheme.onSurface, 53 + ), 54 + ), 55 + TextSpan( 56 + text: author.displayName != null && author.displayName!.isNotEmpty 57 + ? ' @${author.handle}' 58 + : '@${author.handle}', 59 + style: theme.textTheme.bodyMedium?.copyWith( 60 + color: colorScheme.onSurfaceVariant, 61 + ), 62 + ), 63 + ], 64 + ), 65 + ), 66 + ), 67 + ], 68 + ), 69 + if (text.isNotEmpty) ...[ 70 + const SizedBox(height: 8), 71 + Text( 72 + text, 73 + style: theme.textTheme.bodyMedium, 74 + maxLines: 6, 75 + overflow: TextOverflow.ellipsis, 76 + ), 77 + ], 78 + if (imageCount > 0) ...[ 79 + const SizedBox(height: 8), 80 + Row( 81 + children: [ 82 + Icon(Icons.image, size: 16, color: colorScheme.onSurfaceVariant), 83 + const SizedBox(width: 4), 84 + Text( 85 + '$imageCount image${imageCount > 1 ? 's' : ''}', 86 + style: theme.textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), 87 + ), 88 + ], 89 + ), 90 + ], 91 + ], 92 + ), 93 + ); 94 + } 95 + }
+96
lib/src/features/composer/presentation/widgets/reply_context_card.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/src/core/domain/post.dart'; 3 + import 'package:lazurite/src/core/widgets/avatar.dart'; 4 + 5 + /// Displays the parent post context when replying to a post. 6 + /// 7 + /// Shows a compact card with the parent post author and truncated text, 8 + /// with a vertical thread indicator line connecting to the composer below. 9 + class ReplyContextCard extends StatelessWidget { 10 + const ReplyContextCard({required this.author, required this.text, super.key}); 11 + 12 + /// The author of the parent post being replied to. 13 + final Author author; 14 + 15 + /// The text content of the parent post. 16 + final String text; 17 + 18 + @override 19 + Widget build(BuildContext context) { 20 + final theme = Theme.of(context); 21 + final colorScheme = theme.colorScheme; 22 + 23 + return Container( 24 + padding: const EdgeInsets.all(12), 25 + decoration: BoxDecoration( 26 + color: colorScheme.surfaceContainer, 27 + borderRadius: BorderRadius.circular(12), 28 + ), 29 + child: IntrinsicHeight( 30 + child: Row( 31 + crossAxisAlignment: CrossAxisAlignment.stretch, 32 + children: [ 33 + Column( 34 + children: [ 35 + Avatar(imageUrl: author.avatar, radius: 16), 36 + const SizedBox(height: 4), 37 + Expanded( 38 + child: Container( 39 + width: 2, 40 + decoration: BoxDecoration( 41 + color: colorScheme.outlineVariant, 42 + borderRadius: BorderRadius.circular(1), 43 + ), 44 + ), 45 + ), 46 + ], 47 + ), 48 + const SizedBox(width: 12), 49 + Expanded( 50 + child: Column( 51 + crossAxisAlignment: CrossAxisAlignment.start, 52 + children: [ 53 + RichText( 54 + maxLines: 1, 55 + overflow: TextOverflow.ellipsis, 56 + text: TextSpan( 57 + children: [ 58 + if (author.displayName != null && author.displayName!.isNotEmpty) 59 + TextSpan( 60 + text: author.displayName, 61 + style: theme.textTheme.bodyMedium?.copyWith( 62 + fontWeight: FontWeight.w600, 63 + color: colorScheme.onSurface, 64 + ), 65 + ), 66 + TextSpan( 67 + text: author.displayName != null && author.displayName!.isNotEmpty 68 + ? ' @${author.handle}' 69 + : '@${author.handle}', 70 + style: theme.textTheme.bodyMedium?.copyWith( 71 + color: colorScheme.onSurfaceVariant, 72 + ), 73 + ), 74 + ], 75 + ), 76 + ), 77 + if (text.isNotEmpty) ...[ 78 + const SizedBox(height: 4), 79 + Text( 80 + text, 81 + style: theme.textTheme.bodyMedium?.copyWith( 82 + color: colorScheme.onSurfaceVariant, 83 + ), 84 + maxLines: 3, 85 + overflow: TextOverflow.ellipsis, 86 + ), 87 + ], 88 + ], 89 + ), 90 + ), 91 + ], 92 + ), 93 + ), 94 + ); 95 + } 96 + }
+156
test/src/features/composer/presentation/widgets/quote_post_card_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/core/domain/post.dart'; 4 + import 'package:lazurite/src/features/composer/presentation/widgets/quote_post_card.dart'; 5 + 6 + import '../../../../../helpers/pump_app.dart'; 7 + 8 + void main() { 9 + group('QuotePostCard', () { 10 + const authorWithDisplayName = Author( 11 + did: 'did:plc:quote123', 12 + handle: 'quoted.bsky.social', 13 + displayName: 'Quoted Author', 14 + avatar: 'https://example.com/quote-avatar.jpg', 15 + ); 16 + 17 + const authorWithoutDisplayName = Author( 18 + did: 'did:plc:quote456', 19 + handle: 'handleonly.bsky.social', 20 + ); 21 + 22 + testWidgets('renders author avatar', (tester) async { 23 + await tester.pumpApp( 24 + const Scaffold( 25 + body: QuotePostCard(author: authorWithDisplayName, text: 'Quoted text'), 26 + ), 27 + ); 28 + 29 + expect(find.byType(CircleAvatar), findsOneWidget); 30 + }); 31 + 32 + testWidgets('renders display name and handle when display name exists', (tester) async { 33 + await tester.pumpApp( 34 + const Scaffold( 35 + body: QuotePostCard(author: authorWithDisplayName, text: 'Quoted text'), 36 + ), 37 + ); 38 + 39 + final richText = find.byType(RichText); 40 + expect(richText, findsWidgets); 41 + 42 + final richTextWidget = tester 43 + .widgetList<RichText>(richText) 44 + .firstWhere( 45 + (w) => w.text.toPlainText().contains('Quoted Author'), 46 + orElse: () => throw TestFailure('RichText with display name not found'), 47 + ); 48 + expect(richTextWidget.text.toPlainText(), contains('@quoted.bsky.social')); 49 + }); 50 + 51 + testWidgets('renders only handle when display name is null', (tester) async { 52 + await tester.pumpApp( 53 + const Scaffold( 54 + body: QuotePostCard(author: authorWithoutDisplayName, text: 'Quote content'), 55 + ), 56 + ); 57 + 58 + final richText = find.byType(RichText); 59 + expect(richText, findsWidgets); 60 + 61 + final richTextWidget = tester 62 + .widgetList<RichText>(richText) 63 + .firstWhere( 64 + (w) => w.text.toPlainText().contains('@handleonly.bsky.social'), 65 + orElse: () => throw TestFailure('RichText with handle not found'), 66 + ); 67 + expect(richTextWidget.text.toPlainText(), '@handleonly.bsky.social'); 68 + }); 69 + 70 + testWidgets('renders post text', (tester) async { 71 + await tester.pumpApp( 72 + const Scaffold( 73 + body: QuotePostCard(author: authorWithDisplayName, text: 'This is the quoted post'), 74 + ), 75 + ); 76 + 77 + expect(find.text('This is the quoted post'), findsOneWidget); 78 + }); 79 + 80 + testWidgets('does not render text section when text is empty', (tester) async { 81 + await tester.pumpApp( 82 + const Scaffold( 83 + body: QuotePostCard(author: authorWithDisplayName, text: ''), 84 + ), 85 + ); 86 + 87 + final richText = find.byType(RichText); 88 + expect(richText, findsWidgets); 89 + }); 90 + 91 + testWidgets('shows image count badge when images present', (tester) async { 92 + await tester.pumpApp( 93 + const Scaffold( 94 + body: QuotePostCard( 95 + author: authorWithDisplayName, 96 + text: 'Post with images', 97 + imageCount: 3, 98 + ), 99 + ), 100 + ); 101 + 102 + expect(find.byIcon(Icons.image), findsOneWidget); 103 + expect(find.text('3 images'), findsOneWidget); 104 + }); 105 + 106 + testWidgets('shows singular image text for single image', (tester) async { 107 + await tester.pumpApp( 108 + const Scaffold( 109 + body: QuotePostCard( 110 + author: authorWithDisplayName, 111 + text: 'Post with image', 112 + imageCount: 1, 113 + ), 114 + ), 115 + ); 116 + 117 + expect(find.byIcon(Icons.image), findsOneWidget); 118 + expect(find.text('1 image'), findsOneWidget); 119 + }); 120 + 121 + testWidgets('does not show image badge when imageCount is zero', (tester) async { 122 + await tester.pumpApp( 123 + const Scaffold( 124 + body: QuotePostCard(author: authorWithDisplayName, text: 'No images'), 125 + ), 126 + ); 127 + 128 + expect(find.byIcon(Icons.image), findsNothing); 129 + }); 130 + 131 + testWidgets('has bordered card styling', (tester) async { 132 + await tester.pumpApp( 133 + const Scaffold( 134 + body: QuotePostCard(author: authorWithDisplayName, text: 'Styled card'), 135 + ), 136 + ); 137 + 138 + final containers = find.byType(Container); 139 + expect(containers, findsWidgets); 140 + }); 141 + 142 + testWidgets('truncates long text', (tester) async { 143 + final longText = 'This is a very long quote ' * 30; 144 + await tester.pumpApp( 145 + Scaffold( 146 + body: QuotePostCard(author: authorWithDisplayName, text: longText), 147 + ), 148 + ); 149 + 150 + final textFinder = find.byWidgetPredicate( 151 + (widget) => widget is Text && widget.overflow == TextOverflow.ellipsis, 152 + ); 153 + expect(textFinder, findsWidgets); 154 + }); 155 + }); 156 + }
+113
test/src/features/composer/presentation/widgets/reply_context_card_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/core/domain/post.dart'; 4 + import 'package:lazurite/src/features/composer/presentation/widgets/reply_context_card.dart'; 5 + 6 + import '../../../../../helpers/pump_app.dart'; 7 + 8 + void main() { 9 + group('ReplyContextCard', () { 10 + const authorWithDisplayName = Author( 11 + did: 'did:plc:test123', 12 + handle: 'testuser.bsky.social', 13 + displayName: 'Test User', 14 + avatar: 'https://example.com/avatar.jpg', 15 + ); 16 + 17 + const authorWithoutDisplayName = Author(did: 'did:plc:test456', handle: 'noname.bsky.social'); 18 + 19 + testWidgets('renders author avatar', (tester) async { 20 + await tester.pumpApp( 21 + const Scaffold( 22 + body: ReplyContextCard(author: authorWithDisplayName, text: 'Hello world'), 23 + ), 24 + ); 25 + 26 + expect(find.byType(CircleAvatar), findsOneWidget); 27 + }); 28 + 29 + testWidgets('renders display name and handle when display name exists', (tester) async { 30 + await tester.pumpApp( 31 + const Scaffold( 32 + body: ReplyContextCard(author: authorWithDisplayName, text: 'Hello world'), 33 + ), 34 + ); 35 + 36 + final richText = find.byType(RichText); 37 + expect(richText, findsWidgets); 38 + 39 + final richTextWidget = tester 40 + .widgetList<RichText>(richText) 41 + .firstWhere( 42 + (w) => w.text.toPlainText().contains('Test User'), 43 + orElse: () => throw TestFailure('RichText with display name not found'), 44 + ); 45 + expect(richTextWidget.text.toPlainText(), contains('@testuser.bsky.social')); 46 + }); 47 + 48 + testWidgets('renders only handle when display name is null', (tester) async { 49 + await tester.pumpApp( 50 + const Scaffold( 51 + body: ReplyContextCard(author: authorWithoutDisplayName, text: 'Reply text'), 52 + ), 53 + ); 54 + 55 + final richText = find.byType(RichText); 56 + expect(richText, findsWidgets); 57 + 58 + final richTextWidget = tester 59 + .widgetList<RichText>(richText) 60 + .firstWhere( 61 + (w) => w.text.toPlainText().contains('@noname.bsky.social'), 62 + orElse: () => throw TestFailure('RichText with handle not found'), 63 + ); 64 + expect(richTextWidget.text.toPlainText(), '@noname.bsky.social'); 65 + }); 66 + 67 + testWidgets('renders post text', (tester) async { 68 + await tester.pumpApp( 69 + const Scaffold( 70 + body: ReplyContextCard(author: authorWithDisplayName, text: 'Some reply content here'), 71 + ), 72 + ); 73 + 74 + expect(find.text('Some reply content here'), findsOneWidget); 75 + }); 76 + 77 + testWidgets('does not render text section when text is empty', (tester) async { 78 + await tester.pumpApp( 79 + const Scaffold( 80 + body: ReplyContextCard(author: authorWithDisplayName, text: ''), 81 + ), 82 + ); 83 + 84 + final richText = find.byType(RichText); 85 + expect(richText, findsWidgets); 86 + }); 87 + 88 + testWidgets('shows thread indicator line', (tester) async { 89 + await tester.pumpApp( 90 + const Scaffold( 91 + body: ReplyContextCard(author: authorWithDisplayName, text: 'Text for thread'), 92 + ), 93 + ); 94 + 95 + final containers = find.byType(Container); 96 + expect(containers, findsWidgets); 97 + }); 98 + 99 + testWidgets('truncates long text', (tester) async { 100 + final longText = 'This is a very long text ' * 20; 101 + await tester.pumpApp( 102 + Scaffold( 103 + body: ReplyContextCard(author: authorWithDisplayName, text: longText), 104 + ), 105 + ); 106 + 107 + final textFinder = find.byWidgetPredicate( 108 + (widget) => widget is Text && widget.overflow == TextOverflow.ellipsis, 109 + ); 110 + expect(textFinder, findsWidgets); 111 + }); 112 + }); 113 + }