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

feat: add external link embedding to composer

+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
··· 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
··· 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
··· 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;
+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 }
+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 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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 });
+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
··· 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 }