+14
-14
doc/roadmap.txt
+14
-14
doc/roadmap.txt
···
153
153
- [x] Calculate byte offsets (UTF-8 encoding)
154
154
- [x] Build facet array
155
155
- [x] Handle unicode and emoji correctly
156
-
- [ ] Integrate facet parsing:
157
-
- [ ] Parse text on publish
158
-
- [ ] Store facets in facetsJson
159
-
- [ ] Include facets in post record
160
-
- [ ] Visual styling for mentions
161
-
- [ ] Visual styling for links
162
-
- [ ] Link card preview:
163
-
- [ ] Detect URLs in text
164
-
- [ ] Fetch link metadata (unfurling)
165
-
- [ ] Display link card preview
166
-
- [ ] Include external embed
156
+
- [x] Integrate facet parsing:
157
+
- [x] Parse text on publish
158
+
- [x] Store facets in facetsJson
159
+
- [x] Include facets in post record
160
+
- [x] Visual styling for mentions
161
+
- [x] Visual styling for links
162
+
- [x] Link card preview:
163
+
- [x] Detect URLs in text
164
+
- [x] Fetch link metadata (unfurling)
165
+
- [x] Display link card preview
166
+
- [x] Include external embed
167
167
168
168
Phase 6 - Global FAB and Polish (HIGH):
169
169
- [ ] Create global FAB widget:
···
178
178
- [ ] Smooth show/hide animation
179
179
- [ ] Tap navigates to /compose
180
180
- [ ] Create new draft automatically
181
-
- [ ] Performance optimization:
182
-
- [ ] Image thumbnail caching
183
-
- [ ] Smooth, animated modal transitions
181
+
- [ ] Performance optimization:
182
+
- [ ] Image thumbnail caching
183
+
- [ ] Smooth, animated modal transitions
184
184
185
185
Tests:
186
186
- [ ] Unit tests:
+18
lib/src/features/composer/application/composer_providers.dart
+18
lib/src/features/composer/application/composer_providers.dart
···
1
+
import 'package:dio/dio.dart';
1
2
import 'package:lazurite/src/app/providers.dart';
2
3
import 'package:lazurite/src/core/utils/logger_provider.dart';
3
4
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
4
5
import 'package:lazurite/src/features/composer/domain/draft.dart' as composer;
6
+
import 'package:lazurite/src/features/composer/domain/facet_parser.dart';
5
7
import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart';
8
+
import 'package:lazurite/src/features/composer/infrastructure/link_metadata_service.dart';
6
9
import 'package:lazurite/src/infrastructure/network/providers.dart';
7
10
import 'package:riverpod_annotation/riverpod_annotation.dart';
8
11
9
12
part 'composer_providers.g.dart';
10
13
11
14
@riverpod
15
+
FacetParser facetParser(Ref ref) {
16
+
final api = ref.watch(xrpcClientProvider);
17
+
final logger = ref.watch(loggerProvider('FacetParser'));
18
+
return FacetParser(api: api, logger: logger);
19
+
}
20
+
21
+
@riverpod
22
+
LinkMetadataService linkMetadataService(Ref ref) {
23
+
final logger = ref.watch(loggerProvider('LinkMetadataService'));
24
+
return LinkMetadataService(dio: Dio(), logger: logger);
25
+
}
26
+
27
+
@riverpod
12
28
DraftRepository draftRepository(Ref ref) {
13
29
final db = ref.watch(appDatabaseProvider);
14
30
final api = ref.watch(xrpcClientProvider);
15
31
final sessionStorage = ref.watch(sessionStorageProvider);
16
32
final logger = ref.watch(loggerProvider('DraftRepository'));
33
+
final facetParser = ref.watch(facetParserProvider);
17
34
18
35
return DraftRepository(
19
36
dao: db.draftsDao,
20
37
api: api,
21
38
sessionStorage: sessionStorage,
22
39
logger: logger,
40
+
facetParser: facetParser,
23
41
);
24
42
}
25
43
+82
-1
lib/src/features/composer/application/composer_providers.g.dart
+82
-1
lib/src/features/composer/application/composer_providers.g.dart
···
9
9
// GENERATED CODE - DO NOT MODIFY BY HAND
10
10
// ignore_for_file: type=lint, type=warning
11
11
12
+
@ProviderFor(facetParser)
13
+
final facetParserProvider = FacetParserProvider._();
14
+
15
+
final class FacetParserProvider extends $FunctionalProvider<FacetParser, FacetParser, FacetParser>
16
+
with $Provider<FacetParser> {
17
+
FacetParserProvider._()
18
+
: super(
19
+
from: null,
20
+
argument: null,
21
+
retry: null,
22
+
name: r'facetParserProvider',
23
+
isAutoDispose: true,
24
+
dependencies: null,
25
+
$allTransitiveDependencies: null,
26
+
);
27
+
28
+
@override
29
+
String debugGetCreateSourceHash() => _$facetParserHash();
30
+
31
+
@$internal
32
+
@override
33
+
$ProviderElement<FacetParser> $createElement($ProviderPointer pointer) =>
34
+
$ProviderElement(pointer);
35
+
36
+
@override
37
+
FacetParser create(Ref ref) {
38
+
return facetParser(ref);
39
+
}
40
+
41
+
/// {@macro riverpod.override_with_value}
42
+
Override overrideWithValue(FacetParser value) {
43
+
return $ProviderOverride(
44
+
origin: this,
45
+
providerOverride: $SyncValueProvider<FacetParser>(value),
46
+
);
47
+
}
48
+
}
49
+
50
+
String _$facetParserHash() => r'3df8a0ee99ce1cb7149075e5f25aa4535ad7d2bf';
51
+
52
+
@ProviderFor(linkMetadataService)
53
+
final linkMetadataServiceProvider = LinkMetadataServiceProvider._();
54
+
55
+
final class LinkMetadataServiceProvider
56
+
extends $FunctionalProvider<LinkMetadataService, LinkMetadataService, LinkMetadataService>
57
+
with $Provider<LinkMetadataService> {
58
+
LinkMetadataServiceProvider._()
59
+
: super(
60
+
from: null,
61
+
argument: null,
62
+
retry: null,
63
+
name: r'linkMetadataServiceProvider',
64
+
isAutoDispose: true,
65
+
dependencies: null,
66
+
$allTransitiveDependencies: null,
67
+
);
68
+
69
+
@override
70
+
String debugGetCreateSourceHash() => _$linkMetadataServiceHash();
71
+
72
+
@$internal
73
+
@override
74
+
$ProviderElement<LinkMetadataService> $createElement($ProviderPointer pointer) =>
75
+
$ProviderElement(pointer);
76
+
77
+
@override
78
+
LinkMetadataService create(Ref ref) {
79
+
return linkMetadataService(ref);
80
+
}
81
+
82
+
/// {@macro riverpod.override_with_value}
83
+
Override overrideWithValue(LinkMetadataService value) {
84
+
return $ProviderOverride(
85
+
origin: this,
86
+
providerOverride: $SyncValueProvider<LinkMetadataService>(value),
87
+
);
88
+
}
89
+
}
90
+
91
+
String _$linkMetadataServiceHash() => r'f6d106408351a8efbea835eb1b18b52d0b5ef8e2';
92
+
12
93
@ProviderFor(draftRepository)
13
94
final draftRepositoryProvider = DraftRepositoryProvider._();
14
95
···
48
129
}
49
130
}
50
131
51
-
String _$draftRepositoryHash() => r'cf8a45807ae0504c8969416ed0ab88bdc3dfb537';
132
+
String _$draftRepositoryHash() => r'69131f522db3287a9da5df8a3cfc99ef66993e54';
52
133
53
134
@ProviderFor(drafts)
54
135
final draftsProvider = DraftsProvider._();
+8
lib/src/features/composer/domain/draft.dart
+8
lib/src/features/composer/domain/draft.dart
···
17
17
this.quoteUri,
18
18
this.quoteCid,
19
19
this.facetsJson,
20
+
this.externalUri,
21
+
this.externalTitle,
22
+
this.externalDescription,
23
+
this.externalThumbBlobJson,
20
24
this.errorMessage,
21
25
});
22
26
···
29
33
final String? quoteUri;
30
34
final String? quoteCid;
31
35
final String? facetsJson;
36
+
final String? externalUri;
37
+
final String? externalTitle;
38
+
final String? externalDescription;
39
+
final String? externalThumbBlobJson;
32
40
final DraftStatus status;
33
41
final String? errorMessage;
34
42
final DateTime createdAt;
+34
lib/src/features/composer/domain/link_metadata.dart
+34
lib/src/features/composer/domain/link_metadata.dart
···
1
+
/// Metadata extracted from a URL for link card previews.
2
+
///
3
+
/// Contains Open Graph tags and fallback data from HTML head tags.
4
+
class LinkMetadata {
5
+
LinkMetadata({required this.url, this.title, this.description, this.imageUrl, this.siteName});
6
+
7
+
/// The original URL that was fetched.
8
+
final String url;
9
+
10
+
/// Page title from og:title or <title> tag.
11
+
final String? title;
12
+
13
+
/// Page description from og:description or meta description.
14
+
final String? description;
15
+
16
+
/// Preview image URL from og:image.
17
+
final String? imageUrl;
18
+
19
+
/// Site name from og:site_name.
20
+
final String? siteName;
21
+
22
+
/// Whether this metadata has any useful information.
23
+
bool get hasContent => title != null || description != null || imageUrl != null;
24
+
25
+
Map<String, dynamic> toJson() {
26
+
return {
27
+
'url': url,
28
+
if (title != null) 'title': title,
29
+
if (description != null) 'description': description,
30
+
if (imageUrl != null) 'imageUrl': imageUrl,
31
+
if (siteName != null) 'siteName': siteName,
32
+
};
33
+
}
34
+
}
+43
lib/src/features/composer/infrastructure/draft_repository.dart
+43
lib/src/features/composer/infrastructure/draft_repository.dart
···
6
6
import 'package:lazurite/src/core/utils/image_compressor.dart';
7
7
import 'package:lazurite/src/core/utils/logger.dart';
8
8
import 'package:lazurite/src/features/composer/domain/draft.dart' as composer;
9
+
import 'package:lazurite/src/features/composer/domain/facet_parser.dart';
9
10
import 'package:lazurite/src/infrastructure/auth/session_storage.dart';
10
11
import 'package:lazurite/src/infrastructure/db/app_database.dart';
11
12
import 'package:lazurite/src/infrastructure/db/daos/drafts_dao.dart';
···
18
19
required XrpcClient api,
19
20
required SessionStorage sessionStorage,
20
21
required Logger logger,
22
+
required FacetParser facetParser,
21
23
ImageCompressor? compressor,
22
24
}) : _dao = dao,
23
25
_api = api,
24
26
_sessionStorage = sessionStorage,
25
27
_logger = logger,
28
+
_facetParser = facetParser,
26
29
_compressor = compressor ?? const ImageCompressor(),
27
30
_uuid = const Uuid();
28
31
···
30
33
final XrpcClient _api;
31
34
final SessionStorage _sessionStorage;
32
35
final Logger _logger;
36
+
final FacetParser _facetParser;
33
37
final ImageCompressor _compressor;
34
38
final Uuid _uuid;
35
39
final Map<int, CancelToken> _uploadCancelTokens = {};
···
181
185
182
186
try {
183
187
final domain = _toDomain(draft);
188
+
189
+
final facetsJson = await _facetParser.parse(domain.text);
190
+
if (facetsJson != null && facetsJson != domain.facetsJson) {
191
+
await _dao.updateDraftFields(draftId, DraftsCompanion(facetsJson: Value(facetsJson)));
192
+
}
193
+
184
194
for (final media in domain.media) {
185
195
if (media.requiresUpload) {
186
196
final blob = await _uploadMedia(
···
352
362
quoteUri: record.draft.quoteUri,
353
363
quoteCid: record.draft.quoteCid,
354
364
facetsJson: record.draft.facetsJson,
365
+
externalUri: record.draft.externalUri,
366
+
externalTitle: record.draft.externalTitle,
367
+
externalDescription: record.draft.externalDescription,
368
+
externalThumbBlobJson: record.draft.externalThumbBlobJson,
355
369
status: _statusFromDb(record.draft.status),
356
370
errorMessage: record.draft.errorMessage,
357
371
createdAt: record.draft.createdAt,
···
450
464
451
465
final imagesEmbed = _buildImagesEmbed(draft.media);
452
466
final quoteEmbed = _buildQuoteEmbed(draft);
467
+
final externalEmbed = _buildExternalEmbed(draft);
453
468
454
469
Map<String, dynamic>? embed;
455
470
if (imagesEmbed != null && quoteEmbed != null) {
···
458
473
'record': quoteEmbed,
459
474
'media': imagesEmbed,
460
475
};
476
+
} else if (imagesEmbed != null && externalEmbed != null) {
477
+
_logger.warning('Cannot combine images and external link embed, using images only');
478
+
embed = imagesEmbed;
461
479
} else if (imagesEmbed != null) {
462
480
embed = imagesEmbed;
463
481
} else if (quoteEmbed != null) {
464
482
embed = quoteEmbed;
483
+
} else if (externalEmbed != null) {
484
+
embed = externalEmbed;
465
485
}
466
486
467
487
if (embed != null) {
···
524
544
'\$type': 'app.bsky.embed.record',
525
545
'record': {'uri': draft.quoteUri, if (draft.quoteCid != null) 'cid': draft.quoteCid},
526
546
};
547
+
}
548
+
549
+
Map<String, dynamic>? _buildExternalEmbed(composer.Draft draft) {
550
+
if (draft.externalUri == null) {
551
+
return null;
552
+
}
553
+
554
+
final external = <String, dynamic>{
555
+
'uri': draft.externalUri!,
556
+
if (draft.externalTitle != null) 'title': draft.externalTitle!,
557
+
if (draft.externalDescription != null) 'description': draft.externalDescription!,
558
+
};
559
+
560
+
if (draft.externalThumbBlobJson != null) {
561
+
try {
562
+
final thumbBlob = jsonDecode(draft.externalThumbBlobJson!);
563
+
external['thumb'] = thumbBlob;
564
+
} catch (e) {
565
+
_logger.warning('Failed to decode external thumb blob for ${draft.id}: $e');
566
+
}
567
+
}
568
+
569
+
return {'\$type': 'app.bsky.embed.external', 'external': external};
527
570
}
528
571
}
+113
lib/src/features/composer/infrastructure/link_metadata_service.dart
+113
lib/src/features/composer/infrastructure/link_metadata_service.dart
···
1
+
import 'package:dio/dio.dart';
2
+
import 'package:html/parser.dart' as html_parser;
3
+
import 'package:lazurite/src/core/utils/logger.dart';
4
+
import 'package:lazurite/src/features/composer/domain/link_metadata.dart';
5
+
6
+
/// Service for fetching and parsing URL metadata for link card previews.
7
+
///
8
+
/// Fetches HTML content and extracts Open Graph tags and fallback metadata
9
+
/// from standard HTML meta tags.
10
+
class LinkMetadataService {
11
+
LinkMetadataService({required Dio dio, required Logger logger}) : _dio = dio, _logger = logger;
12
+
13
+
final Dio _dio;
14
+
final Logger _logger;
15
+
16
+
/// Fetches metadata for a given URL.
17
+
///
18
+
/// Returns null if the URL cannot be fetched or parsed.
19
+
/// Caches results for performance (future enhancement).
20
+
Future<LinkMetadata?> fetchMetadata(String url) async {
21
+
try {
22
+
final normalizedUrl = _normalizeUrl(url);
23
+
if (normalizedUrl == null) {
24
+
_logger.warning('Invalid URL: $url');
25
+
return null;
26
+
}
27
+
28
+
final response = await _dio.get<String>(
29
+
normalizedUrl,
30
+
options: Options(
31
+
followRedirects: true,
32
+
maxRedirects: 5,
33
+
validateStatus: (status) => status != null && status < 400,
34
+
headers: {
35
+
'User-Agent':
36
+
'Mozilla/5.0 (compatible; Lazurite/1.0; +https://github.com/bluesky-social/lazurite)',
37
+
},
38
+
),
39
+
);
40
+
41
+
if (response.data == null) {
42
+
_logger.warning('Empty response for URL: $normalizedUrl');
43
+
return null;
44
+
}
45
+
46
+
return _parseHtml(normalizedUrl, response.data!);
47
+
} on DioException catch (e) {
48
+
_logger.warning('Failed to fetch URL metadata: $url', e);
49
+
return null;
50
+
} catch (e, stack) {
51
+
_logger.error('Unexpected error fetching URL metadata: $url', e, stack);
52
+
return null;
53
+
}
54
+
}
55
+
56
+
/// Normalizes a URL by adding protocol if missing and validating format.
57
+
String? _normalizeUrl(String url) {
58
+
var normalized = url.trim();
59
+
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
60
+
normalized = 'https://$normalized';
61
+
}
62
+
63
+
try {
64
+
final uri = Uri.parse(normalized);
65
+
if (!uri.hasScheme || !uri.hasAuthority) {
66
+
return null;
67
+
}
68
+
return normalized;
69
+
} catch (e) {
70
+
return null;
71
+
}
72
+
}
73
+
74
+
/// Parses HTML content to extract metadata.
75
+
///
76
+
/// Prioritizes Open Graph tags, falls back to standard HTML meta tags.
77
+
LinkMetadata _parseHtml(String url, String html) {
78
+
final document = html_parser.parse(html);
79
+
80
+
String? getMetaContent(String property, {String attribute = 'property'}) {
81
+
final element = document.querySelector('meta[$attribute="$property"]');
82
+
return element?.attributes['content'];
83
+
}
84
+
85
+
final ogTitle = getMetaContent('og:title');
86
+
final ogDescription = getMetaContent('og:description');
87
+
final ogImage = getMetaContent('og:image');
88
+
final ogSiteName = getMetaContent('og:site_name');
89
+
90
+
final title =
91
+
ogTitle ??
92
+
getMetaContent('twitter:title', attribute: 'name') ??
93
+
document.querySelector('title')?.text;
94
+
95
+
final description =
96
+
ogDescription ??
97
+
getMetaContent('twitter:description', attribute: 'name') ??
98
+
getMetaContent('description', attribute: 'name');
99
+
100
+
final imageUrl = ogImage ?? getMetaContent('twitter:image', attribute: 'name');
101
+
102
+
final siteName =
103
+
ogSiteName ?? getMetaContent('twitter:site', attribute: 'name') ?? Uri.parse(url).host;
104
+
105
+
return LinkMetadata(
106
+
url: url,
107
+
title: title?.trim(),
108
+
description: description?.trim(),
109
+
imageUrl: imageUrl?.trim(),
110
+
siteName: siteName.trim(),
111
+
);
112
+
}
113
+
}
+110
-2
lib/src/features/composer/presentation/widgets/composer_text_field.dart
+110
-2
lib/src/features/composer/presentation/widgets/composer_text_field.dart
···
1
+
import 'package:extended_text_field/extended_text_field.dart';
1
2
import 'package:flutter/material.dart';
2
3
3
-
/// Multi-line text field for composing posts with character count.
4
+
/// Multi-line text field for composing posts with character count and rich text styling.
5
+
///
6
+
/// Automatically styles mentions (@handle), links (URLs), and hashtags (#tag) with
7
+
/// distinct colors as the user types.
4
8
class ComposerTextField extends StatelessWidget {
5
9
const ComposerTextField({
6
10
required this.controller,
···
33
37
mainAxisSize: MainAxisSize.min,
34
38
crossAxisAlignment: CrossAxisAlignment.stretch,
35
39
children: [
36
-
TextField(
40
+
ExtendedTextField(
37
41
controller: controller,
38
42
maxLines: null,
39
43
minLines: 4,
···
45
49
),
46
50
style: theme.textTheme.bodyLarge,
47
51
onChanged: onChanged,
52
+
specialTextSpanBuilder: ComposerTextSpanBuilder(
53
+
mentionColor: colorScheme.primary,
54
+
linkColor: colorScheme.tertiary,
55
+
hashtagColor: colorScheme.secondary,
56
+
),
48
57
),
49
58
Padding(
50
59
padding: const EdgeInsets.only(right: 8.0),
···
74
83
);
75
84
}
76
85
}
86
+
87
+
/// Custom text span builder that styles mentions, links, and hashtags.
88
+
class ComposerTextSpanBuilder extends SpecialTextSpanBuilder {
89
+
ComposerTextSpanBuilder({
90
+
required this.mentionColor,
91
+
required this.linkColor,
92
+
required this.hashtagColor,
93
+
});
94
+
95
+
final Color mentionColor;
96
+
final Color linkColor;
97
+
final Color hashtagColor;
98
+
99
+
@override
100
+
SpecialText? createSpecialText(
101
+
String flag, {
102
+
TextStyle? textStyle,
103
+
SpecialTextGestureTapCallback? onTap,
104
+
int? index,
105
+
}) {
106
+
if (flag.isEmpty) {
107
+
return null;
108
+
}
109
+
110
+
if (flag == '@') {
111
+
return MentionText(textStyle: textStyle, color: mentionColor, onTap: onTap);
112
+
}
113
+
114
+
if (flag == '#') {
115
+
return HashtagText(textStyle: textStyle, color: hashtagColor, onTap: onTap);
116
+
}
117
+
118
+
if (flag == 'http://' || flag == 'https://') {
119
+
return LinkText(textStyle: textStyle, color: linkColor, onTap: onTap);
120
+
}
121
+
122
+
return null;
123
+
}
124
+
}
125
+
126
+
/// Styled text span for @mentions.
127
+
class MentionText extends SpecialText {
128
+
MentionText({
129
+
required TextStyle? textStyle,
130
+
required this.color,
131
+
SpecialTextGestureTapCallback? onTap,
132
+
}) : super('@', RegExp(r'\s|$').pattern, textStyle, onTap: onTap);
133
+
134
+
final Color color;
135
+
136
+
@override
137
+
InlineSpan finishText() {
138
+
final text = toString();
139
+
return TextSpan(
140
+
text: text,
141
+
style: textStyle?.copyWith(color: color, fontWeight: FontWeight.w600),
142
+
);
143
+
}
144
+
}
145
+
146
+
/// Styled text span for #hashtags.
147
+
class HashtagText extends SpecialText {
148
+
HashtagText({
149
+
required TextStyle? textStyle,
150
+
required this.color,
151
+
SpecialTextGestureTapCallback? onTap,
152
+
}) : super('#', RegExp(r'\s|$').pattern, textStyle, onTap: onTap);
153
+
154
+
final Color color;
155
+
156
+
@override
157
+
InlineSpan finishText() {
158
+
final text = toString();
159
+
return TextSpan(
160
+
text: text,
161
+
style: textStyle?.copyWith(color: color, fontWeight: FontWeight.w600),
162
+
);
163
+
}
164
+
}
165
+
166
+
/// Styled text span for URLs.
167
+
class LinkText extends SpecialText {
168
+
LinkText({
169
+
required TextStyle? textStyle,
170
+
required this.color,
171
+
SpecialTextGestureTapCallback? onTap,
172
+
}) : super('http', RegExp(r'\s|$').pattern, textStyle, onTap: onTap);
173
+
174
+
final Color color;
175
+
176
+
@override
177
+
InlineSpan finishText() {
178
+
final text = toString();
179
+
return TextSpan(
180
+
text: text,
181
+
style: textStyle?.copyWith(color: color, decoration: TextDecoration.underline),
182
+
);
183
+
}
184
+
}
+92
lib/src/features/composer/presentation/widgets/link_card_preview.dart
+92
lib/src/features/composer/presentation/widgets/link_card_preview.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:lazurite/src/features/composer/domain/link_metadata.dart';
3
+
4
+
/// Displays a preview card for a link with metadata.
5
+
///
6
+
/// Shows title, description, image, and site name extracted from URL metadata.
7
+
class LinkCardPreview extends StatelessWidget {
8
+
const LinkCardPreview({required this.metadata, this.onRemove, super.key});
9
+
10
+
/// Metadata to display.
11
+
final LinkMetadata metadata;
12
+
13
+
/// Callback fired when user taps remove button.
14
+
final VoidCallback? onRemove;
15
+
16
+
@override
17
+
Widget build(BuildContext context) {
18
+
final theme = Theme.of(context);
19
+
final colorScheme = theme.colorScheme;
20
+
21
+
return Card(
22
+
margin: const EdgeInsets.symmetric(vertical: 8.0),
23
+
clipBehavior: Clip.antiAlias,
24
+
child: IntrinsicHeight(
25
+
child: Row(
26
+
crossAxisAlignment: CrossAxisAlignment.stretch,
27
+
children: [
28
+
if (metadata.imageUrl != null)
29
+
SizedBox(
30
+
width: 120,
31
+
child: Image.network(
32
+
metadata.imageUrl!,
33
+
fit: BoxFit.cover,
34
+
errorBuilder: (context, error, stackTrace) {
35
+
return Container(
36
+
color: colorScheme.surfaceContainerHighest,
37
+
child: Icon(Icons.broken_image, color: colorScheme.onSurfaceVariant),
38
+
);
39
+
},
40
+
),
41
+
),
42
+
Expanded(
43
+
child: Padding(
44
+
padding: const EdgeInsets.all(12.0),
45
+
child: Column(
46
+
crossAxisAlignment: CrossAxisAlignment.start,
47
+
children: [
48
+
if (metadata.siteName != null)
49
+
Text(
50
+
metadata.siteName!,
51
+
style: theme.textTheme.labelSmall?.copyWith(
52
+
color: colorScheme.onSurfaceVariant,
53
+
),
54
+
maxLines: 1,
55
+
overflow: TextOverflow.ellipsis,
56
+
),
57
+
if (metadata.title != null) ...[
58
+
const SizedBox(height: 4),
59
+
Text(
60
+
metadata.title!,
61
+
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
62
+
maxLines: 2,
63
+
overflow: TextOverflow.ellipsis,
64
+
),
65
+
],
66
+
if (metadata.description != null) ...[
67
+
const SizedBox(height: 4),
68
+
Text(
69
+
metadata.description!,
70
+
style: theme.textTheme.bodySmall?.copyWith(
71
+
color: colorScheme.onSurfaceVariant,
72
+
),
73
+
maxLines: 2,
74
+
overflow: TextOverflow.ellipsis,
75
+
),
76
+
],
77
+
],
78
+
),
79
+
),
80
+
),
81
+
if (onRemove != null)
82
+
IconButton(
83
+
icon: const Icon(Icons.close),
84
+
onPressed: onRemove,
85
+
tooltip: 'Remove link preview',
86
+
),
87
+
],
88
+
),
89
+
),
90
+
);
91
+
}
92
+
}
+274
lib/src/infrastructure/db/app_database.g.dart
+274
lib/src/infrastructure/db/app_database.g.dart
···
4486
4486
type: DriftSqlType.string,
4487
4487
requiredDuringInsert: false,
4488
4488
);
4489
+
static const VerificationMeta _externalUriMeta = const VerificationMeta('externalUri');
4490
+
@override
4491
+
late final GeneratedColumn<String> externalUri = GeneratedColumn<String>(
4492
+
'external_uri',
4493
+
aliasedName,
4494
+
true,
4495
+
type: DriftSqlType.string,
4496
+
requiredDuringInsert: false,
4497
+
);
4498
+
static const VerificationMeta _externalTitleMeta = const VerificationMeta('externalTitle');
4499
+
@override
4500
+
late final GeneratedColumn<String> externalTitle = GeneratedColumn<String>(
4501
+
'external_title',
4502
+
aliasedName,
4503
+
true,
4504
+
type: DriftSqlType.string,
4505
+
requiredDuringInsert: false,
4506
+
);
4507
+
static const VerificationMeta _externalDescriptionMeta = const VerificationMeta(
4508
+
'externalDescription',
4509
+
);
4510
+
@override
4511
+
late final GeneratedColumn<String> externalDescription = GeneratedColumn<String>(
4512
+
'external_description',
4513
+
aliasedName,
4514
+
true,
4515
+
type: DriftSqlType.string,
4516
+
requiredDuringInsert: false,
4517
+
);
4518
+
static const VerificationMeta _externalThumbBlobJsonMeta = const VerificationMeta(
4519
+
'externalThumbBlobJson',
4520
+
);
4521
+
@override
4522
+
late final GeneratedColumn<String> externalThumbBlobJson = GeneratedColumn<String>(
4523
+
'external_thumb_blob_json',
4524
+
aliasedName,
4525
+
true,
4526
+
type: DriftSqlType.string,
4527
+
requiredDuringInsert: false,
4528
+
);
4489
4529
static const VerificationMeta _statusMeta = const VerificationMeta('status');
4490
4530
@override
4491
4531
late final GeneratedColumn<String> status = GeneratedColumn<String>(
···
4533
4573
quoteUri,
4534
4574
quoteCid,
4535
4575
facetsJson,
4576
+
externalUri,
4577
+
externalTitle,
4578
+
externalDescription,
4579
+
externalThumbBlobJson,
4536
4580
status,
4537
4581
errorMessage,
4538
4582
createdAt,
···
4597
4641
facetsJson.isAcceptableOrUnknown(data['facets_json']!, _facetsJsonMeta),
4598
4642
);
4599
4643
}
4644
+
if (data.containsKey('external_uri')) {
4645
+
context.handle(
4646
+
_externalUriMeta,
4647
+
externalUri.isAcceptableOrUnknown(data['external_uri']!, _externalUriMeta),
4648
+
);
4649
+
}
4650
+
if (data.containsKey('external_title')) {
4651
+
context.handle(
4652
+
_externalTitleMeta,
4653
+
externalTitle.isAcceptableOrUnknown(data['external_title']!, _externalTitleMeta),
4654
+
);
4655
+
}
4656
+
if (data.containsKey('external_description')) {
4657
+
context.handle(
4658
+
_externalDescriptionMeta,
4659
+
externalDescription.isAcceptableOrUnknown(
4660
+
data['external_description']!,
4661
+
_externalDescriptionMeta,
4662
+
),
4663
+
);
4664
+
}
4665
+
if (data.containsKey('external_thumb_blob_json')) {
4666
+
context.handle(
4667
+
_externalThumbBlobJsonMeta,
4668
+
externalThumbBlobJson.isAcceptableOrUnknown(
4669
+
data['external_thumb_blob_json']!,
4670
+
_externalThumbBlobJsonMeta,
4671
+
),
4672
+
);
4673
+
}
4600
4674
if (data.containsKey('status')) {
4601
4675
context.handle(_statusMeta, status.isAcceptableOrUnknown(data['status']!, _statusMeta));
4602
4676
} else if (isInserting) {
···
4666
4740
DriftSqlType.string,
4667
4741
data['${effectivePrefix}facets_json'],
4668
4742
),
4743
+
externalUri: attachedDatabase.typeMapping.read(
4744
+
DriftSqlType.string,
4745
+
data['${effectivePrefix}external_uri'],
4746
+
),
4747
+
externalTitle: attachedDatabase.typeMapping.read(
4748
+
DriftSqlType.string,
4749
+
data['${effectivePrefix}external_title'],
4750
+
),
4751
+
externalDescription: attachedDatabase.typeMapping.read(
4752
+
DriftSqlType.string,
4753
+
data['${effectivePrefix}external_description'],
4754
+
),
4755
+
externalThumbBlobJson: attachedDatabase.typeMapping.read(
4756
+
DriftSqlType.string,
4757
+
data['${effectivePrefix}external_thumb_blob_json'],
4758
+
),
4669
4759
status: attachedDatabase.typeMapping.read(
4670
4760
DriftSqlType.string,
4671
4761
data['${effectivePrefix}status'],
···
4701
4791
final String? quoteUri;
4702
4792
final String? quoteCid;
4703
4793
final String? facetsJson;
4794
+
final String? externalUri;
4795
+
final String? externalTitle;
4796
+
final String? externalDescription;
4797
+
final String? externalThumbBlobJson;
4704
4798
final String status;
4705
4799
final String? errorMessage;
4706
4800
final DateTime createdAt;
···
4715
4809
this.quoteUri,
4716
4810
this.quoteCid,
4717
4811
this.facetsJson,
4812
+
this.externalUri,
4813
+
this.externalTitle,
4814
+
this.externalDescription,
4815
+
this.externalThumbBlobJson,
4718
4816
required this.status,
4719
4817
this.errorMessage,
4720
4818
required this.createdAt,
···
4746
4844
if (!nullToAbsent || facetsJson != null) {
4747
4845
map['facets_json'] = Variable<String>(facetsJson);
4748
4846
}
4847
+
if (!nullToAbsent || externalUri != null) {
4848
+
map['external_uri'] = Variable<String>(externalUri);
4849
+
}
4850
+
if (!nullToAbsent || externalTitle != null) {
4851
+
map['external_title'] = Variable<String>(externalTitle);
4852
+
}
4853
+
if (!nullToAbsent || externalDescription != null) {
4854
+
map['external_description'] = Variable<String>(externalDescription);
4855
+
}
4856
+
if (!nullToAbsent || externalThumbBlobJson != null) {
4857
+
map['external_thumb_blob_json'] = Variable<String>(externalThumbBlobJson);
4858
+
}
4749
4859
map['status'] = Variable<String>(status);
4750
4860
if (!nullToAbsent || errorMessage != null) {
4751
4861
map['error_message'] = Variable<String>(errorMessage);
···
4774
4884
quoteUri: quoteUri == null && nullToAbsent ? const Value.absent() : Value(quoteUri),
4775
4885
quoteCid: quoteCid == null && nullToAbsent ? const Value.absent() : Value(quoteCid),
4776
4886
facetsJson: facetsJson == null && nullToAbsent ? const Value.absent() : Value(facetsJson),
4887
+
externalUri: externalUri == null && nullToAbsent ? const Value.absent() : Value(externalUri),
4888
+
externalTitle: externalTitle == null && nullToAbsent
4889
+
? const Value.absent()
4890
+
: Value(externalTitle),
4891
+
externalDescription: externalDescription == null && nullToAbsent
4892
+
? const Value.absent()
4893
+
: Value(externalDescription),
4894
+
externalThumbBlobJson: externalThumbBlobJson == null && nullToAbsent
4895
+
? const Value.absent()
4896
+
: Value(externalThumbBlobJson),
4777
4897
status: Value(status),
4778
4898
errorMessage: errorMessage == null && nullToAbsent
4779
4899
? const Value.absent()
···
4795
4915
quoteUri: serializer.fromJson<String?>(json['quoteUri']),
4796
4916
quoteCid: serializer.fromJson<String?>(json['quoteCid']),
4797
4917
facetsJson: serializer.fromJson<String?>(json['facetsJson']),
4918
+
externalUri: serializer.fromJson<String?>(json['externalUri']),
4919
+
externalTitle: serializer.fromJson<String?>(json['externalTitle']),
4920
+
externalDescription: serializer.fromJson<String?>(json['externalDescription']),
4921
+
externalThumbBlobJson: serializer.fromJson<String?>(json['externalThumbBlobJson']),
4798
4922
status: serializer.fromJson<String>(json['status']),
4799
4923
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
4800
4924
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
···
4814
4938
'quoteUri': serializer.toJson<String?>(quoteUri),
4815
4939
'quoteCid': serializer.toJson<String?>(quoteCid),
4816
4940
'facetsJson': serializer.toJson<String?>(facetsJson),
4941
+
'externalUri': serializer.toJson<String?>(externalUri),
4942
+
'externalTitle': serializer.toJson<String?>(externalTitle),
4943
+
'externalDescription': serializer.toJson<String?>(externalDescription),
4944
+
'externalThumbBlobJson': serializer.toJson<String?>(externalThumbBlobJson),
4817
4945
'status': serializer.toJson<String>(status),
4818
4946
'errorMessage': serializer.toJson<String?>(errorMessage),
4819
4947
'createdAt': serializer.toJson<DateTime>(createdAt),
···
4831
4959
Value<String?> quoteUri = const Value.absent(),
4832
4960
Value<String?> quoteCid = const Value.absent(),
4833
4961
Value<String?> facetsJson = const Value.absent(),
4962
+
Value<String?> externalUri = const Value.absent(),
4963
+
Value<String?> externalTitle = const Value.absent(),
4964
+
Value<String?> externalDescription = const Value.absent(),
4965
+
Value<String?> externalThumbBlobJson = const Value.absent(),
4834
4966
String? status,
4835
4967
Value<String?> errorMessage = const Value.absent(),
4836
4968
DateTime? createdAt,
···
4845
4977
quoteUri: quoteUri.present ? quoteUri.value : this.quoteUri,
4846
4978
quoteCid: quoteCid.present ? quoteCid.value : this.quoteCid,
4847
4979
facetsJson: facetsJson.present ? facetsJson.value : this.facetsJson,
4980
+
externalUri: externalUri.present ? externalUri.value : this.externalUri,
4981
+
externalTitle: externalTitle.present ? externalTitle.value : this.externalTitle,
4982
+
externalDescription: externalDescription.present
4983
+
? externalDescription.value
4984
+
: this.externalDescription,
4985
+
externalThumbBlobJson: externalThumbBlobJson.present
4986
+
? externalThumbBlobJson.value
4987
+
: this.externalThumbBlobJson,
4848
4988
status: status ?? this.status,
4849
4989
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
4850
4990
createdAt: createdAt ?? this.createdAt,
···
4865
5005
quoteUri: data.quoteUri.present ? data.quoteUri.value : this.quoteUri,
4866
5006
quoteCid: data.quoteCid.present ? data.quoteCid.value : this.quoteCid,
4867
5007
facetsJson: data.facetsJson.present ? data.facetsJson.value : this.facetsJson,
5008
+
externalUri: data.externalUri.present ? data.externalUri.value : this.externalUri,
5009
+
externalTitle: data.externalTitle.present ? data.externalTitle.value : this.externalTitle,
5010
+
externalDescription: data.externalDescription.present
5011
+
? data.externalDescription.value
5012
+
: this.externalDescription,
5013
+
externalThumbBlobJson: data.externalThumbBlobJson.present
5014
+
? data.externalThumbBlobJson.value
5015
+
: this.externalThumbBlobJson,
4868
5016
status: data.status.present ? data.status.value : this.status,
4869
5017
errorMessage: data.errorMessage.present ? data.errorMessage.value : this.errorMessage,
4870
5018
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
···
4884
5032
..write('quoteUri: $quoteUri, ')
4885
5033
..write('quoteCid: $quoteCid, ')
4886
5034
..write('facetsJson: $facetsJson, ')
5035
+
..write('externalUri: $externalUri, ')
5036
+
..write('externalTitle: $externalTitle, ')
5037
+
..write('externalDescription: $externalDescription, ')
5038
+
..write('externalThumbBlobJson: $externalThumbBlobJson, ')
4887
5039
..write('status: $status, ')
4888
5040
..write('errorMessage: $errorMessage, ')
4889
5041
..write('createdAt: $createdAt, ')
···
4903
5055
quoteUri,
4904
5056
quoteCid,
4905
5057
facetsJson,
5058
+
externalUri,
5059
+
externalTitle,
5060
+
externalDescription,
5061
+
externalThumbBlobJson,
4906
5062
status,
4907
5063
errorMessage,
4908
5064
createdAt,
···
4921
5077
other.quoteUri == this.quoteUri &&
4922
5078
other.quoteCid == this.quoteCid &&
4923
5079
other.facetsJson == this.facetsJson &&
5080
+
other.externalUri == this.externalUri &&
5081
+
other.externalTitle == this.externalTitle &&
5082
+
other.externalDescription == this.externalDescription &&
5083
+
other.externalThumbBlobJson == this.externalThumbBlobJson &&
4924
5084
other.status == this.status &&
4925
5085
other.errorMessage == this.errorMessage &&
4926
5086
other.createdAt == this.createdAt &&
···
4937
5097
final Value<String?> quoteUri;
4938
5098
final Value<String?> quoteCid;
4939
5099
final Value<String?> facetsJson;
5100
+
final Value<String?> externalUri;
5101
+
final Value<String?> externalTitle;
5102
+
final Value<String?> externalDescription;
5103
+
final Value<String?> externalThumbBlobJson;
4940
5104
final Value<String> status;
4941
5105
final Value<String?> errorMessage;
4942
5106
final Value<DateTime> createdAt;
···
4952
5116
this.quoteUri = const Value.absent(),
4953
5117
this.quoteCid = const Value.absent(),
4954
5118
this.facetsJson = const Value.absent(),
5119
+
this.externalUri = const Value.absent(),
5120
+
this.externalTitle = const Value.absent(),
5121
+
this.externalDescription = const Value.absent(),
5122
+
this.externalThumbBlobJson = const Value.absent(),
4955
5123
this.status = const Value.absent(),
4956
5124
this.errorMessage = const Value.absent(),
4957
5125
this.createdAt = const Value.absent(),
···
4968
5136
this.quoteUri = const Value.absent(),
4969
5137
this.quoteCid = const Value.absent(),
4970
5138
this.facetsJson = const Value.absent(),
5139
+
this.externalUri = const Value.absent(),
5140
+
this.externalTitle = const Value.absent(),
5141
+
this.externalDescription = const Value.absent(),
5142
+
this.externalThumbBlobJson = const Value.absent(),
4971
5143
required String status,
4972
5144
this.errorMessage = const Value.absent(),
4973
5145
required DateTime createdAt,
···
4987
5159
Expression<String>? quoteUri,
4988
5160
Expression<String>? quoteCid,
4989
5161
Expression<String>? facetsJson,
5162
+
Expression<String>? externalUri,
5163
+
Expression<String>? externalTitle,
5164
+
Expression<String>? externalDescription,
5165
+
Expression<String>? externalThumbBlobJson,
4990
5166
Expression<String>? status,
4991
5167
Expression<String>? errorMessage,
4992
5168
Expression<DateTime>? createdAt,
···
5003
5179
if (quoteUri != null) 'quote_uri': quoteUri,
5004
5180
if (quoteCid != null) 'quote_cid': quoteCid,
5005
5181
if (facetsJson != null) 'facets_json': facetsJson,
5182
+
if (externalUri != null) 'external_uri': externalUri,
5183
+
if (externalTitle != null) 'external_title': externalTitle,
5184
+
if (externalDescription != null) 'external_description': externalDescription,
5185
+
if (externalThumbBlobJson != null) 'external_thumb_blob_json': externalThumbBlobJson,
5006
5186
if (status != null) 'status': status,
5007
5187
if (errorMessage != null) 'error_message': errorMessage,
5008
5188
if (createdAt != null) 'created_at': createdAt,
···
5021
5201
Value<String?>? quoteUri,
5022
5202
Value<String?>? quoteCid,
5023
5203
Value<String?>? facetsJson,
5204
+
Value<String?>? externalUri,
5205
+
Value<String?>? externalTitle,
5206
+
Value<String?>? externalDescription,
5207
+
Value<String?>? externalThumbBlobJson,
5024
5208
Value<String>? status,
5025
5209
Value<String?>? errorMessage,
5026
5210
Value<DateTime>? createdAt,
···
5037
5221
quoteUri: quoteUri ?? this.quoteUri,
5038
5222
quoteCid: quoteCid ?? this.quoteCid,
5039
5223
facetsJson: facetsJson ?? this.facetsJson,
5224
+
externalUri: externalUri ?? this.externalUri,
5225
+
externalTitle: externalTitle ?? this.externalTitle,
5226
+
externalDescription: externalDescription ?? this.externalDescription,
5227
+
externalThumbBlobJson: externalThumbBlobJson ?? this.externalThumbBlobJson,
5040
5228
status: status ?? this.status,
5041
5229
errorMessage: errorMessage ?? this.errorMessage,
5042
5230
createdAt: createdAt ?? this.createdAt,
···
5075
5263
if (facetsJson.present) {
5076
5264
map['facets_json'] = Variable<String>(facetsJson.value);
5077
5265
}
5266
+
if (externalUri.present) {
5267
+
map['external_uri'] = Variable<String>(externalUri.value);
5268
+
}
5269
+
if (externalTitle.present) {
5270
+
map['external_title'] = Variable<String>(externalTitle.value);
5271
+
}
5272
+
if (externalDescription.present) {
5273
+
map['external_description'] = Variable<String>(externalDescription.value);
5274
+
}
5275
+
if (externalThumbBlobJson.present) {
5276
+
map['external_thumb_blob_json'] = Variable<String>(externalThumbBlobJson.value);
5277
+
}
5078
5278
if (status.present) {
5079
5279
map['status'] = Variable<String>(status.value);
5080
5280
}
···
5105
5305
..write('quoteUri: $quoteUri, ')
5106
5306
..write('quoteCid: $quoteCid, ')
5107
5307
..write('facetsJson: $facetsJson, ')
5308
+
..write('externalUri: $externalUri, ')
5309
+
..write('externalTitle: $externalTitle, ')
5310
+
..write('externalDescription: $externalDescription, ')
5311
+
..write('externalThumbBlobJson: $externalThumbBlobJson, ')
5108
5312
..write('status: $status, ')
5109
5313
..write('errorMessage: $errorMessage, ')
5110
5314
..write('createdAt: $createdAt, ')
···
10288
10492
Value<String?> quoteUri,
10289
10493
Value<String?> quoteCid,
10290
10494
Value<String?> facetsJson,
10495
+
Value<String?> externalUri,
10496
+
Value<String?> externalTitle,
10497
+
Value<String?> externalDescription,
10498
+
Value<String?> externalThumbBlobJson,
10291
10499
required String status,
10292
10500
Value<String?> errorMessage,
10293
10501
required DateTime createdAt,
···
10305
10513
Value<String?> quoteUri,
10306
10514
Value<String?> quoteCid,
10307
10515
Value<String?> facetsJson,
10516
+
Value<String?> externalUri,
10517
+
Value<String?> externalTitle,
10518
+
Value<String?> externalDescription,
10519
+
Value<String?> externalThumbBlobJson,
10308
10520
Value<String> status,
10309
10521
Value<String?> errorMessage,
10310
10522
Value<DateTime> createdAt,
···
10372
10584
ColumnFilters<String> get facetsJson =>
10373
10585
$composableBuilder(column: $table.facetsJson, builder: (column) => ColumnFilters(column));
10374
10586
10587
+
ColumnFilters<String> get externalUri =>
10588
+
$composableBuilder(column: $table.externalUri, builder: (column) => ColumnFilters(column));
10589
+
10590
+
ColumnFilters<String> get externalTitle =>
10591
+
$composableBuilder(column: $table.externalTitle, builder: (column) => ColumnFilters(column));
10592
+
10593
+
ColumnFilters<String> get externalDescription => $composableBuilder(
10594
+
column: $table.externalDescription,
10595
+
builder: (column) => ColumnFilters(column),
10596
+
);
10597
+
10598
+
ColumnFilters<String> get externalThumbBlobJson => $composableBuilder(
10599
+
column: $table.externalThumbBlobJson,
10600
+
builder: (column) => ColumnFilters(column),
10601
+
);
10602
+
10375
10603
ColumnFilters<String> get status =>
10376
10604
$composableBuilder(column: $table.status, builder: (column) => ColumnFilters(column));
10377
10605
···
10447
10675
ColumnOrderings<String> get facetsJson =>
10448
10676
$composableBuilder(column: $table.facetsJson, builder: (column) => ColumnOrderings(column));
10449
10677
10678
+
ColumnOrderings<String> get externalUri =>
10679
+
$composableBuilder(column: $table.externalUri, builder: (column) => ColumnOrderings(column));
10680
+
10681
+
ColumnOrderings<String> get externalTitle => $composableBuilder(
10682
+
column: $table.externalTitle,
10683
+
builder: (column) => ColumnOrderings(column),
10684
+
);
10685
+
10686
+
ColumnOrderings<String> get externalDescription => $composableBuilder(
10687
+
column: $table.externalDescription,
10688
+
builder: (column) => ColumnOrderings(column),
10689
+
);
10690
+
10691
+
ColumnOrderings<String> get externalThumbBlobJson => $composableBuilder(
10692
+
column: $table.externalThumbBlobJson,
10693
+
builder: (column) => ColumnOrderings(column),
10694
+
);
10695
+
10450
10696
ColumnOrderings<String> get status =>
10451
10697
$composableBuilder(column: $table.status, builder: (column) => ColumnOrderings(column));
10452
10698
···
10497
10743
GeneratedColumn<String> get facetsJson =>
10498
10744
$composableBuilder(column: $table.facetsJson, builder: (column) => column);
10499
10745
10746
+
GeneratedColumn<String> get externalUri =>
10747
+
$composableBuilder(column: $table.externalUri, builder: (column) => column);
10748
+
10749
+
GeneratedColumn<String> get externalTitle =>
10750
+
$composableBuilder(column: $table.externalTitle, builder: (column) => column);
10751
+
10752
+
GeneratedColumn<String> get externalDescription =>
10753
+
$composableBuilder(column: $table.externalDescription, builder: (column) => column);
10754
+
10755
+
GeneratedColumn<String> get externalThumbBlobJson =>
10756
+
$composableBuilder(column: $table.externalThumbBlobJson, builder: (column) => column);
10757
+
10500
10758
GeneratedColumn<String> get status =>
10501
10759
$composableBuilder(column: $table.status, builder: (column) => column);
10502
10760
···
10566
10824
Value<String?> quoteUri = const Value.absent(),
10567
10825
Value<String?> quoteCid = const Value.absent(),
10568
10826
Value<String?> facetsJson = const Value.absent(),
10827
+
Value<String?> externalUri = const Value.absent(),
10828
+
Value<String?> externalTitle = const Value.absent(),
10829
+
Value<String?> externalDescription = const Value.absent(),
10830
+
Value<String?> externalThumbBlobJson = const Value.absent(),
10569
10831
Value<String> status = const Value.absent(),
10570
10832
Value<String?> errorMessage = const Value.absent(),
10571
10833
Value<DateTime> createdAt = const Value.absent(),
···
10581
10843
quoteUri: quoteUri,
10582
10844
quoteCid: quoteCid,
10583
10845
facetsJson: facetsJson,
10846
+
externalUri: externalUri,
10847
+
externalTitle: externalTitle,
10848
+
externalDescription: externalDescription,
10849
+
externalThumbBlobJson: externalThumbBlobJson,
10584
10850
status: status,
10585
10851
errorMessage: errorMessage,
10586
10852
createdAt: createdAt,
···
10598
10864
Value<String?> quoteUri = const Value.absent(),
10599
10865
Value<String?> quoteCid = const Value.absent(),
10600
10866
Value<String?> facetsJson = const Value.absent(),
10867
+
Value<String?> externalUri = const Value.absent(),
10868
+
Value<String?> externalTitle = const Value.absent(),
10869
+
Value<String?> externalDescription = const Value.absent(),
10870
+
Value<String?> externalThumbBlobJson = const Value.absent(),
10601
10871
required String status,
10602
10872
Value<String?> errorMessage = const Value.absent(),
10603
10873
required DateTime createdAt,
···
10613
10883
quoteUri: quoteUri,
10614
10884
quoteCid: quoteCid,
10615
10885
facetsJson: facetsJson,
10886
+
externalUri: externalUri,
10887
+
externalTitle: externalTitle,
10888
+
externalDescription: externalDescription,
10889
+
externalThumbBlobJson: externalThumbBlobJson,
10616
10890
status: status,
10617
10891
errorMessage: errorMessage,
10618
10892
createdAt: createdAt,
+4
lib/src/infrastructure/db/tables.dart
+4
lib/src/infrastructure/db/tables.dart
···
277
277
TextColumn get quoteUri => text().nullable()();
278
278
TextColumn get quoteCid => text().nullable()();
279
279
TextColumn get facetsJson => text().nullable()();
280
+
TextColumn get externalUri => text().nullable()();
281
+
TextColumn get externalTitle => text().nullable()();
282
+
TextColumn get externalDescription => text().nullable()();
283
+
TextColumn get externalThumbBlobJson => text().nullable()();
280
284
TextColumn get status => text()();
281
285
TextColumn get errorMessage => text().nullable()();
282
286
DateTimeColumn get createdAt => dateTime()();
+32
pubspec.lock
+32
pubspec.lock
···
241
241
url: "https://pub.dev"
242
242
source: hosted
243
243
version: "0.3.0+2"
244
+
csslib:
245
+
dependency: transitive
246
+
description:
247
+
name: csslib
248
+
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
249
+
url: "https://pub.dev"
250
+
source: hosted
251
+
version: "1.0.2"
244
252
cupertino_icons:
245
253
dependency: "direct main"
246
254
description:
···
321
329
url: "https://pub.dev"
322
330
source: hosted
323
331
version: "2.0.8"
332
+
extended_text_field:
333
+
dependency: "direct main"
334
+
description:
335
+
name: extended_text_field
336
+
sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d"
337
+
url: "https://pub.dev"
338
+
source: hosted
339
+
version: "16.0.2"
340
+
extended_text_library:
341
+
dependency: transitive
342
+
description:
343
+
name: extended_text_library
344
+
sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba"
345
+
url: "https://pub.dev"
346
+
source: hosted
347
+
version: "12.0.1"
324
348
fake_async:
325
349
dependency: "direct dev"
326
350
description:
···
576
600
url: "https://pub.dev"
577
601
source: hosted
578
602
version: "2.3.2"
603
+
html:
604
+
dependency: "direct main"
605
+
description:
606
+
name: html
607
+
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
608
+
url: "https://pub.dev"
609
+
source: hosted
610
+
version: "0.15.6"
579
611
http:
580
612
dependency: transitive
581
613
description:
+2
pubspec.yaml
+2
pubspec.yaml
···
52
52
collection: ^1.19.1
53
53
characters: ^1.4.0
54
54
shelf: ^1.4.2
55
+
html: ^0.15.5
55
56
56
57
# Persistence
57
58
drift: ^2.24.0
···
64
65
image_picker: ^1.1.2
65
66
flutter_image_compress: ^2.3.0
66
67
equatable: ^2.0.8
68
+
extended_text_field: ^16.0.2
67
69
68
70
dev_dependencies:
69
71
flutter_test:
+3
test/helpers/mocks.dart
+3
test/helpers/mocks.dart
···
6
6
import 'package:lazurite/src/core/utils/image_compressor.dart';
7
7
import 'package:lazurite/src/core/utils/logger.dart';
8
8
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
9
+
import 'package:lazurite/src/features/composer/domain/facet_parser.dart';
9
10
import 'package:lazurite/src/features/feeds/application/feed_providers.dart';
10
11
import 'package:lazurite/src/features/feeds/infrastructure/feed_content_repository.dart';
11
12
import 'package:lazurite/src/features/feeds/infrastructure/feed_repository.dart';
···
26
27
class MockLogger extends Mock implements Logger {}
27
28
28
29
class MockImageCompressor extends Mock implements ImageCompressor {}
30
+
31
+
class MockFacetParser extends Mock implements FacetParser {}
29
32
30
33
class MockXrpcClient extends Mock implements XrpcClient {}
31
34
+6
test/src/features/composer/infrastructure/draft_repository_test.dart
+6
test/src/features/composer/infrastructure/draft_repository_test.dart
···
23
23
late MockXrpcClient mockApi;
24
24
late MockSessionStorage mockSessionStorage;
25
25
late MockLogger mockLogger;
26
+
late MockFacetParser mockFacetParser;
26
27
late Directory tempDir;
27
28
28
29
setUp(() {
···
30
31
mockApi = MockXrpcClient();
31
32
mockSessionStorage = MockSessionStorage();
32
33
mockLogger = MockLogger();
34
+
mockFacetParser = MockFacetParser();
35
+
36
+
when(() => mockFacetParser.parse(any())).thenAnswer((_) async => null);
37
+
33
38
repository = DraftRepository(
34
39
dao: db.draftsDao,
35
40
api: mockApi,
36
41
sessionStorage: mockSessionStorage,
37
42
logger: mockLogger,
43
+
facetParser: mockFacetParser,
38
44
);
39
45
tempDir = Directory.systemTemp.createTempSync('draft_repo_test');
40
46
});
+159
test/src/features/composer/infrastructure/link_metadata_service_test.dart
+159
test/src/features/composer/infrastructure/link_metadata_service_test.dart
···
1
+
import 'package:dio/dio.dart';
2
+
import 'package:flutter_test/flutter_test.dart';
3
+
import 'package:lazurite/src/core/utils/logger.dart';
4
+
import 'package:lazurite/src/features/composer/infrastructure/link_metadata_service.dart';
5
+
import 'package:mocktail/mocktail.dart';
6
+
7
+
class MockDio extends Mock implements Dio {}
8
+
9
+
class MockLogger extends Mock implements Logger {}
10
+
11
+
void main() {
12
+
group('LinkMetadataService', () {
13
+
late MockDio mockDio;
14
+
late MockLogger mockLogger;
15
+
late LinkMetadataService service;
16
+
17
+
setUp(() {
18
+
mockDio = MockDio();
19
+
mockLogger = MockLogger();
20
+
service = LinkMetadataService(dio: mockDio, logger: mockLogger);
21
+
});
22
+
23
+
test('parses Open Graph metadata from HTML', () async {
24
+
const url = 'https://example.com';
25
+
const html = '''
26
+
<html>
27
+
<head>
28
+
<meta property="og:title" content="Example Title" />
29
+
<meta property="og:description" content="Example Description" />
30
+
<meta property="og:image" content="https://example.com/image.jpg" />
31
+
<meta property="og:site_name" content="Example Site" />
32
+
</head>
33
+
</html>
34
+
''';
35
+
36
+
when(() => mockDio.get<String>(url, options: any(named: 'options'))).thenAnswer(
37
+
(_) async => Response<String>(
38
+
data: html,
39
+
statusCode: 200,
40
+
requestOptions: RequestOptions(path: url),
41
+
),
42
+
);
43
+
44
+
final metadata = await service.fetchMetadata(url);
45
+
46
+
expect(metadata, isNotNull);
47
+
expect(metadata!.url, equals(url));
48
+
expect(metadata.title, equals('Example Title'));
49
+
expect(metadata.description, equals('Example Description'));
50
+
expect(metadata.imageUrl, equals('https://example.com/image.jpg'));
51
+
expect(metadata.siteName, equals('Example Site'));
52
+
});
53
+
54
+
test('falls back to standard HTML tags when Open Graph tags missing', () async {
55
+
const url = 'https://example.com';
56
+
const html = '''
57
+
<html>
58
+
<head>
59
+
<title>Fallback Title</title>
60
+
<meta name="description" content="Fallback Description" />
61
+
</head>
62
+
</html>
63
+
''';
64
+
65
+
when(() => mockDio.get<String>(url, options: any(named: 'options'))).thenAnswer(
66
+
(_) async => Response<String>(
67
+
data: html,
68
+
statusCode: 200,
69
+
requestOptions: RequestOptions(path: url),
70
+
),
71
+
);
72
+
73
+
final metadata = await service.fetchMetadata(url);
74
+
75
+
expect(metadata, isNotNull);
76
+
expect(metadata!.title, equals('Fallback Title'));
77
+
expect(metadata.description, equals('Fallback Description'));
78
+
});
79
+
80
+
test('normalizes URL by adding https:// if missing', () async {
81
+
const url = 'example.com';
82
+
const normalizedUrl = 'https://example.com';
83
+
const html = '<html><head><title>Test</title></head></html>';
84
+
85
+
when(() => mockDio.get<String>(normalizedUrl, options: any(named: 'options'))).thenAnswer(
86
+
(_) async => Response<String>(
87
+
data: html,
88
+
statusCode: 200,
89
+
requestOptions: RequestOptions(path: normalizedUrl),
90
+
),
91
+
);
92
+
93
+
final metadata = await service.fetchMetadata(url);
94
+
95
+
expect(metadata, isNotNull);
96
+
expect(metadata!.url, equals(normalizedUrl));
97
+
verify(() => mockDio.get<String>(normalizedUrl, options: any(named: 'options'))).called(1);
98
+
});
99
+
100
+
test('returns null for invalid URLs', () async {
101
+
const url = ':::invalid:::';
102
+
103
+
final metadata = await service.fetchMetadata(url);
104
+
105
+
expect(metadata, isNull);
106
+
verifyNever(() => mockDio.get<String>(any(), options: any(named: 'options')));
107
+
});
108
+
109
+
test('returns null when network request fails', () async {
110
+
const url = 'https://example.com';
111
+
112
+
when(() => mockDio.get<String>(url, options: any(named: 'options'))).thenThrow(
113
+
DioException(
114
+
requestOptions: RequestOptions(path: url),
115
+
type: DioExceptionType.connectionTimeout,
116
+
),
117
+
);
118
+
119
+
final metadata = await service.fetchMetadata(url);
120
+
121
+
expect(metadata, isNull);
122
+
verify(() => mockLogger.warning(any(), any())).called(1);
123
+
});
124
+
125
+
test('returns null for empty response', () async {
126
+
const url = 'https://example.com';
127
+
128
+
when(() => mockDio.get<String>(url, options: any(named: 'options'))).thenAnswer(
129
+
(_) async => Response<String>(
130
+
data: null,
131
+
statusCode: 200,
132
+
requestOptions: RequestOptions(path: url),
133
+
),
134
+
);
135
+
136
+
final metadata = await service.fetchMetadata(url);
137
+
138
+
expect(metadata, isNull);
139
+
});
140
+
141
+
test('hasContent returns true when metadata has title, description, or image', () async {
142
+
const url = 'https://example.com';
143
+
const html = '<html><head><title>Test</title></head></html>';
144
+
145
+
when(() => mockDio.get<String>(url, options: any(named: 'options'))).thenAnswer(
146
+
(_) async => Response<String>(
147
+
data: html,
148
+
statusCode: 200,
149
+
requestOptions: RequestOptions(path: url),
150
+
),
151
+
);
152
+
153
+
final metadata = await service.fetchMetadata(url);
154
+
155
+
expect(metadata, isNotNull);
156
+
expect(metadata!.hasContent, isTrue);
157
+
});
158
+
});
159
+
}
+27
-21
test/src/features/composer/presentation/screens/composer_screen_test.dart
+27
-21
test/src/features/composer/presentation/screens/composer_screen_test.dart
···
1
+
import 'package:extended_text_field/extended_text_field.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4
import 'package:flutter_test/flutter_test.dart';
···
114
115
await tester.pumpAndSettle();
115
116
}
116
117
118
+
Future<void> enterText(WidgetTester tester, String text) async {
119
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
120
+
textField.controller?.text = text;
121
+
await tester.pump();
122
+
}
123
+
117
124
group('ComposerScreen', () {
118
125
testWidgets('renders text field and publish button', (tester) async {
119
126
await tester.pumpWidget(buildTestWidget());
120
127
await navigateToCompose(tester);
121
128
122
-
expect(find.byType(TextField), findsOneWidget);
129
+
expect(find.byType(ExtendedTextField), findsOneWidget);
123
130
expect(find.byType(PublishButton), findsOneWidget);
124
131
});
125
132
···
156
163
await tester.pumpWidget(buildTestWidget());
157
164
await navigateToCompose(tester);
158
165
159
-
await tester.enterText(find.byType(TextField), 'Hello');
160
-
await tester.pump();
166
+
await enterText(tester, 'Hello');
161
167
162
168
expect(find.text('295'), findsWidgets);
163
169
});
···
166
172
await tester.pumpWidget(buildTestWidget());
167
173
await navigateToCompose(tester);
168
174
169
-
await tester.enterText(find.byType(TextField), 'Hello world');
175
+
await enterText(tester, 'Hello world');
170
176
await tester.pump();
171
177
172
178
final publishButton = tester.widget<PublishButton>(find.byType(PublishButton));
···
178
184
await navigateToCompose(tester);
179
185
180
186
final longText = 'a' * 305;
181
-
await tester.enterText(find.byType(TextField), longText);
187
+
await enterText(tester, longText);
182
188
await tester.pump();
183
189
184
190
expect(find.text('-5'), findsWidgets);
···
189
195
await navigateToCompose(tester);
190
196
191
197
final longText = 'a' * 305;
192
-
await tester.enterText(find.byType(TextField), longText);
198
+
await enterText(tester, longText);
193
199
await tester.pump();
194
200
195
201
final publishButton = tester.widget<PublishButton>(find.byType(PublishButton));
···
201
207
await navigateToCompose(tester);
202
208
203
209
final longText = 'a' * 305;
204
-
await tester.enterText(find.byType(TextField), longText);
210
+
await enterText(tester, longText);
205
211
await tester.pump();
206
212
207
213
expect(find.text('Split'), findsOneWidget);
···
212
218
await navigateToCompose(tester);
213
219
214
220
final longText = 'a' * 305;
215
-
await tester.enterText(find.byType(TextField), longText);
221
+
await enterText(tester, longText);
216
222
await tester.pump();
217
223
218
224
await tester.tap(find.text('Split'));
219
225
await tester.pump();
220
226
221
-
final textField = tester.widget<TextField>(find.byType(TextField));
227
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
222
228
expect(textField.controller?.text.length, 300);
223
229
224
230
final publishButton = tester.widget<PublishButton>(find.byType(PublishButton));
···
231
237
await navigateToCompose(tester);
232
238
233
239
final text = '${'a' * 290} ${'b' * 15}';
234
-
await tester.enterText(find.byType(TextField), text);
240
+
await enterText(tester, text);
235
241
await tester.pump();
236
242
237
243
await tester.tap(find.text('Split'));
238
244
await tester.pump();
239
245
240
-
final textField = tester.widget<TextField>(find.byType(TextField));
246
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
241
247
expect(textField.controller?.text, '${'a' * 290} ');
242
248
expect(textField.controller?.text.length, 291);
243
249
});
···
247
253
await navigateToCompose(tester);
248
254
249
255
final text = 'a' * 305;
250
-
await tester.enterText(find.byType(TextField), text);
256
+
await enterText(tester, text);
251
257
await tester.pump();
252
258
253
259
await tester.tap(find.text('Split'));
254
260
await tester.pump();
255
261
256
-
final textField = tester.widget<TextField>(find.byType(TextField));
262
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
257
263
expect(textField.controller?.text, 'a' * 300);
258
264
});
259
265
···
275
281
await navigateToCompose(tester);
276
282
277
283
final longText = 'a' * 305;
278
-
await tester.enterText(find.byType(TextField), longText);
284
+
await enterText(tester, longText);
279
285
await tester.pump();
280
286
281
287
await tester.tap(find.text('Split'));
···
301
307
await navigateToCompose(tester);
302
308
303
309
final longText = 'a' * 305;
304
-
await tester.enterText(find.byType(TextField), longText);
310
+
await enterText(tester, longText);
305
311
await tester.pump();
306
312
307
313
await tester.tap(find.text('Split'));
···
332
338
await tester.pumpWidget(buildTestWidget(existingDraft: draft));
333
339
await navigateToCompose(tester);
334
340
335
-
final textField = tester.widget<TextField>(find.byType(TextField));
341
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
336
342
expect(textField.controller?.text, 'Existing draft content');
337
343
});
338
344
···
341
347
await tester.pumpWidget(buildTestWidget(notifier: mockNotifier));
342
348
await navigateToCompose(tester);
343
349
344
-
await tester.enterText(find.byType(TextField), 'Saving on pause');
350
+
await enterText(tester, 'Saving on pause');
345
351
await tester.pump();
346
352
347
353
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
···
409
415
await tester.pumpWidget(buildTestWidget());
410
416
await navigateToCompose(tester);
411
417
412
-
await tester.enterText(find.byType(TextField), 'Content');
418
+
await enterText(tester, 'Content');
413
419
await tester.pump();
414
420
415
421
await tester.tap(find.byIcon(Icons.close));
···
426
432
await tester.pumpWidget(buildTestWidget());
427
433
await navigateToCompose(tester);
428
434
429
-
await tester.enterText(find.byType(TextField), 'Content');
435
+
await enterText(tester, 'Content');
430
436
await tester.pump();
431
437
432
438
await tester.tap(find.byIcon(Icons.close));
···
443
449
await tester.pumpWidget(buildTestWidget());
444
450
await navigateToCompose(tester);
445
451
446
-
await tester.enterText(find.byType(TextField), 'Content');
452
+
await enterText(tester, 'Content');
447
453
await tester.pump();
448
454
449
455
await tester.tap(find.byIcon(Icons.close));
···
460
466
await tester.pumpWidget(buildTestWidget());
461
467
await navigateToCompose(tester);
462
468
463
-
await tester.enterText(find.byType(TextField), 'Content');
469
+
await enterText(tester, 'Content');
464
470
await tester.pump();
465
471
466
472
await tester.tap(find.byIcon(Icons.close));
+145
-4
test/src/features/composer/presentation/widgets/composer_text_field_test.dart
+145
-4
test/src/features/composer/presentation/widgets/composer_text_field_test.dart
···
1
+
import 'package:extended_text_field/extended_text_field.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:flutter_test/flutter_test.dart';
3
4
import 'package:lazurite/src/features/composer/presentation/widgets/composer_text_field.dart';
···
16
17
controller.dispose();
17
18
});
18
19
19
-
testWidgets('renders text field with default hint', (tester) async {
20
+
testWidgets('renders extended text field with default hint', (tester) async {
20
21
await tester.pumpApp(
21
22
SingleChildScrollView(child: ComposerTextField(controller: controller)),
22
23
);
23
-
expect(find.byType(TextField), findsOneWidget);
24
+
expect(find.byType(ExtendedTextField), findsOneWidget);
24
25
expect(find.text("What's happening?"), findsOneWidget);
25
26
});
26
27
···
44
45
await tester.pumpApp(
45
46
SingleChildScrollView(child: ComposerTextField(controller: controller)),
46
47
);
47
-
await tester.enterText(find.byType(TextField), 'Hello');
48
+
controller.text = 'Hello';
48
49
await tester.pump();
49
50
expect(find.text('295'), findsOneWidget);
50
51
});
···
59
60
),
60
61
),
61
62
);
62
-
await tester.enterText(find.byType(TextField), 'Test');
63
+
64
+
final textField = tester.widget<ExtendedTextField>(find.byType(ExtendedTextField));
65
+
textField.onChanged?.call('Test');
63
66
expect(changedValue, 'Test');
64
67
});
65
68
···
79
82
);
80
83
await tester.pump();
81
84
expect(find.text('-3'), findsOneWidget);
85
+
});
86
+
87
+
testWidgets('accepts text with mentions', (tester) async {
88
+
await tester.pumpApp(
89
+
SingleChildScrollView(child: ComposerTextField(controller: controller)),
90
+
);
91
+
controller.text = 'Hello @alice.bsky.social';
92
+
await tester.pump();
93
+
expect(controller.text, 'Hello @alice.bsky.social');
94
+
});
95
+
96
+
testWidgets('accepts text with hashtags', (tester) async {
97
+
await tester.pumpApp(
98
+
SingleChildScrollView(child: ComposerTextField(controller: controller)),
99
+
);
100
+
controller.text = 'Check out #flutter';
101
+
await tester.pump();
102
+
expect(controller.text, 'Check out #flutter');
103
+
});
104
+
105
+
testWidgets('accepts text with URLs', (tester) async {
106
+
await tester.pumpApp(
107
+
SingleChildScrollView(child: ComposerTextField(controller: controller)),
108
+
);
109
+
controller.text = 'Visit https://example.com';
110
+
await tester.pump();
111
+
expect(controller.text, 'Visit https://example.com');
112
+
});
113
+
114
+
testWidgets('accepts text with mixed special text', (tester) async {
115
+
await tester.pumpApp(
116
+
SingleChildScrollView(child: ComposerTextField(controller: controller)),
117
+
);
118
+
const mixedText = 'Hey @bob.bsky.social check #flutter at https://flutter.dev';
119
+
controller.text = mixedText;
120
+
await tester.pump();
121
+
expect(controller.text, mixedText);
122
+
});
123
+
});
124
+
125
+
group('ComposerTextSpanBuilder', () {
126
+
test('creates MentionText for @ flag', () {
127
+
final builder = ComposerTextSpanBuilder(
128
+
mentionColor: Colors.blue,
129
+
linkColor: Colors.purple,
130
+
hashtagColor: Colors.green,
131
+
);
132
+
133
+
final specialText = builder.createSpecialText('@', textStyle: const TextStyle());
134
+
expect(specialText, isA<MentionText>());
135
+
});
136
+
137
+
test('creates HashtagText for # flag', () {
138
+
final builder = ComposerTextSpanBuilder(
139
+
mentionColor: Colors.blue,
140
+
linkColor: Colors.purple,
141
+
hashtagColor: Colors.green,
142
+
);
143
+
144
+
final specialText = builder.createSpecialText('#', textStyle: const TextStyle());
145
+
expect(specialText, isA<HashtagText>());
146
+
});
147
+
148
+
test('creates LinkText for http:// flag', () {
149
+
final builder = ComposerTextSpanBuilder(
150
+
mentionColor: Colors.blue,
151
+
linkColor: Colors.purple,
152
+
hashtagColor: Colors.green,
153
+
);
154
+
155
+
final specialText = builder.createSpecialText('http://', textStyle: const TextStyle());
156
+
expect(specialText, isA<LinkText>());
157
+
});
158
+
159
+
test('creates LinkText for https:// flag', () {
160
+
final builder = ComposerTextSpanBuilder(
161
+
mentionColor: Colors.blue,
162
+
linkColor: Colors.purple,
163
+
hashtagColor: Colors.green,
164
+
);
165
+
166
+
final specialText = builder.createSpecialText('https://', textStyle: const TextStyle());
167
+
expect(specialText, isA<LinkText>());
168
+
});
169
+
170
+
test('returns null for unknown flag', () {
171
+
final builder = ComposerTextSpanBuilder(
172
+
mentionColor: Colors.blue,
173
+
linkColor: Colors.purple,
174
+
hashtagColor: Colors.green,
175
+
);
176
+
177
+
final specialText = builder.createSpecialText('unknown', textStyle: const TextStyle());
178
+
expect(specialText, isNull);
179
+
});
180
+
181
+
test('returns null for empty flag', () {
182
+
final builder = ComposerTextSpanBuilder(
183
+
mentionColor: Colors.blue,
184
+
linkColor: Colors.purple,
185
+
hashtagColor: Colors.green,
186
+
);
187
+
188
+
final specialText = builder.createSpecialText('', textStyle: const TextStyle());
189
+
expect(specialText, isNull);
190
+
});
191
+
});
192
+
193
+
group('MentionText', () {
194
+
test('applies correct styling to mentions', () {
195
+
final mention = MentionText(textStyle: const TextStyle(fontSize: 16), color: Colors.blue);
196
+
mention.appendContent('alice');
197
+
198
+
final span = mention.finishText() as TextSpan;
199
+
expect(span.style?.color, Colors.blue);
200
+
expect(span.style?.fontWeight, FontWeight.w600);
201
+
});
202
+
});
203
+
204
+
group('HashtagText', () {
205
+
test('applies correct styling to hashtags', () {
206
+
final hashtag = HashtagText(textStyle: const TextStyle(fontSize: 16), color: Colors.green);
207
+
hashtag.appendContent('flutter');
208
+
209
+
final span = hashtag.finishText() as TextSpan;
210
+
expect(span.style?.color, Colors.green);
211
+
expect(span.style?.fontWeight, FontWeight.w600);
212
+
});
213
+
});
214
+
215
+
group('LinkText', () {
216
+
test('applies correct styling to links with underline', () {
217
+
final link = LinkText(textStyle: const TextStyle(fontSize: 16), color: Colors.purple);
218
+
link.appendContent('s://example.com');
219
+
220
+
final span = link.finishText() as TextSpan;
221
+
expect(span.style?.color, Colors.purple);
222
+
expect(span.style?.decoration, TextDecoration.underline);
82
223
});
83
224
});
84
225
}
+124
test/src/features/composer/presentation/widgets/link_card_preview_test.dart
+124
test/src/features/composer/presentation/widgets/link_card_preview_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_test/flutter_test.dart';
3
+
import 'package:lazurite/src/features/composer/domain/link_metadata.dart';
4
+
import 'package:lazurite/src/features/composer/presentation/widgets/link_card_preview.dart';
5
+
6
+
void main() {
7
+
group('LinkCardPreview', () {
8
+
testWidgets('displays metadata with title, description, and site name', (tester) async {
9
+
final metadata = LinkMetadata(
10
+
url: 'https://example.com',
11
+
title: 'Example Title',
12
+
description: 'Example Description',
13
+
siteName: 'Example Site',
14
+
);
15
+
16
+
await tester.pumpWidget(
17
+
MaterialApp(
18
+
home: Scaffold(body: LinkCardPreview(metadata: metadata)),
19
+
),
20
+
);
21
+
22
+
expect(find.text('Example Site'), findsOneWidget);
23
+
expect(find.text('Example Title'), findsOneWidget);
24
+
expect(find.text('Example Description'), findsOneWidget);
25
+
});
26
+
27
+
testWidgets('displays image when imageUrl is provided', (tester) async {
28
+
final metadata = LinkMetadata(
29
+
url: 'https://example.com',
30
+
title: 'Example Title',
31
+
imageUrl: 'https://example.com/image.jpg',
32
+
);
33
+
34
+
await tester.pumpWidget(
35
+
MaterialApp(
36
+
home: Scaffold(body: LinkCardPreview(metadata: metadata)),
37
+
),
38
+
);
39
+
40
+
expect(find.byType(Image), findsOneWidget);
41
+
});
42
+
43
+
testWidgets('does not display image when imageUrl is null', (tester) async {
44
+
final metadata = LinkMetadata(url: 'https://example.com', title: 'Example Title');
45
+
46
+
await tester.pumpWidget(
47
+
MaterialApp(
48
+
home: Scaffold(body: LinkCardPreview(metadata: metadata)),
49
+
),
50
+
);
51
+
52
+
expect(find.byType(Image), findsNothing);
53
+
});
54
+
55
+
testWidgets('shows remove button when onRemove callback is provided', (tester) async {
56
+
final metadata = LinkMetadata(url: 'https://example.com', title: 'Example Title');
57
+
var removeCalled = false;
58
+
59
+
await tester.pumpWidget(
60
+
MaterialApp(
61
+
home: Scaffold(
62
+
body: LinkCardPreview(
63
+
metadata: metadata,
64
+
onRemove: () {
65
+
removeCalled = true;
66
+
},
67
+
),
68
+
),
69
+
),
70
+
);
71
+
72
+
expect(find.byIcon(Icons.close), findsOneWidget);
73
+
74
+
await tester.tap(find.byIcon(Icons.close));
75
+
expect(removeCalled, isTrue);
76
+
});
77
+
78
+
testWidgets('does not show remove button when onRemove is null', (tester) async {
79
+
final metadata = LinkMetadata(url: 'https://example.com', title: 'Example Title');
80
+
81
+
await tester.pumpWidget(
82
+
MaterialApp(
83
+
home: Scaffold(body: LinkCardPreview(metadata: metadata)),
84
+
),
85
+
);
86
+
87
+
expect(find.byIcon(Icons.close), findsNothing);
88
+
});
89
+
90
+
testWidgets('truncates long text with ellipsis', (tester) async {
91
+
final metadata = LinkMetadata(
92
+
url: 'https://example.com',
93
+
title: 'This is a very long title that should be truncated with an ellipsis',
94
+
description:
95
+
'This is a very long description that should also be truncated with an ellipsis when it exceeds the maximum number of lines',
96
+
siteName: 'Very Long Site Name That Should Be Truncated',
97
+
);
98
+
99
+
await tester.pumpWidget(
100
+
MaterialApp(
101
+
home: Scaffold(
102
+
body: SizedBox(width: 300, child: LinkCardPreview(metadata: metadata)),
103
+
),
104
+
),
105
+
);
106
+
107
+
await tester.pumpAndSettle();
108
+
109
+
expect(find.byType(LinkCardPreview), findsOneWidget);
110
+
});
111
+
112
+
testWidgets('handles metadata with only URL', (tester) async {
113
+
final metadata = LinkMetadata(url: 'https://example.com');
114
+
115
+
await tester.pumpWidget(
116
+
MaterialApp(
117
+
home: Scaffold(body: LinkCardPreview(metadata: metadata)),
118
+
),
119
+
);
120
+
121
+
expect(find.byType(Card), findsOneWidget);
122
+
});
123
+
});
124
+
}