+12
-12
doc/roadmap.txt
+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
+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
+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
+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
+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
+
}