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

Configure Feed

Select the types of activity you want to include in your feed.

feat: edit profiles & display pronouns + website (#26)

* feat: profile editing functionality with field level validation and image uploads

* feat: move edit profile button to header

* feat: MIME type validation and improved accessibility

authored by

Owais and committed by
GitHub
3bd6f128 8801390e

+1109 -9
+5
lib/core/router/app_router.dart
··· 58 58 import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 59 59 import 'package:lazurite/features/profile/presentation/profile_connections_screen.dart'; 60 60 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 61 + import 'package:lazurite/features/profile/presentation/profile_edit_screen.dart'; 61 62 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 62 63 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 63 64 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; ··· 536 537 path: 'connections', 537 538 pageBuilder: (context, state) => 538 539 _page(context, state, _buildProfileConnectionsRoute(context, state, context.read<String>())), 540 + ), 541 + GoRoute( 542 + path: 'edit', 543 + pageBuilder: (context, state) => _page(context, state, const ProfileEditScreen()), 539 544 ), 540 545 ], 541 546 ),
+110
lib/features/profile/data/profile_repository.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 + import 'dart:typed_data'; 3 4 4 5 import 'package:atproto_core/atproto_core.dart' as atp_core; 5 6 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 7 import 'package:bluesky/app_bsky_feed_defs.dart'; 8 + import 'package:characters/characters.dart'; 7 9 import 'package:bluesky/bluesky.dart'; 8 10 import 'package:lazurite/core/database/app_database.dart'; 9 11 import 'package:lazurite/core/logging/app_logger.dart'; ··· 293 295 } 294 296 } 295 297 298 + Future<void> updateProfile({required String did, required ProfileEditDraft draft}) async { 299 + _validateProfileEditDraft(draft); 300 + log.d('ProfileRepository: Updating profile record for $did'); 301 + 302 + final response = await _authRecovery.run( 303 + (client) => client.atproto.repo.getRecord(repo: did, collection: 'app.bsky.actor.profile', rkey: 'self'), 304 + ); 305 + final currentRecord = Map<String, dynamic>.from(response.data.value as Map); 306 + final updatedRecord = Map<String, dynamic>.from(currentRecord); 307 + updatedRecord['\$type'] = 'app.bsky.actor.profile'; 308 + 309 + _setOptionalString(updatedRecord, 'displayName', draft.displayName); 310 + _setOptionalString(updatedRecord, 'description', draft.description); 311 + _setOptionalString(updatedRecord, 'pronouns', draft.pronouns); 312 + _setOptionalString(updatedRecord, 'website', draft.website); 313 + 314 + final avatar = draft.avatar; 315 + if (avatar != null) { 316 + updatedRecord['avatar'] = (await _uploadProfileBlob(avatar)).toJson(); 317 + } 318 + 319 + final banner = draft.banner; 320 + if (banner != null) { 321 + updatedRecord['banner'] = (await _uploadProfileBlob(banner)).toJson(); 322 + } 323 + 324 + await _authRecovery.run( 325 + (client) => client.atproto.repo.putRecord( 326 + repo: did, 327 + collection: 'app.bsky.actor.profile', 328 + rkey: 'self', 329 + validate: true, 330 + record: updatedRecord, 331 + swapRecord: response.data.cid, 332 + ), 333 + ); 334 + } 335 + 336 + Future<atp_core.Blob> _uploadProfileBlob(ProfileImageUpload upload) async { 337 + final response = await _authRecovery.run( 338 + (client) => client.atproto.repo.uploadBlob( 339 + bytes: Uint8List.fromList(upload.bytes), 340 + $headers: {'Content-Type': upload.mimeType}, 341 + ), 342 + ); 343 + return response.data.blob as atp_core.Blob; 344 + } 345 + 346 + void _setOptionalString(Map<String, dynamic> record, String key, String? value) { 347 + final trimmed = value?.trim(); 348 + if (trimmed == null || trimmed.isEmpty) { 349 + record.remove(key); 350 + return; 351 + } 352 + record[key] = trimmed; 353 + } 354 + 355 + void _validateProfileEditDraft(ProfileEditDraft draft) { 356 + _validateTextLimit('displayName', draft.displayName, maxGraphemes: 64, maxUtf8Bytes: 640); 357 + _validateTextLimit('description', draft.description, maxGraphemes: 256, maxUtf8Bytes: 2560); 358 + _validateTextLimit('pronouns', draft.pronouns, maxGraphemes: 20, maxUtf8Bytes: 200); 359 + _validateProfileImage('avatar', draft.avatar); 360 + _validateProfileImage('banner', draft.banner); 361 + } 362 + 363 + void _validateTextLimit(String field, String? value, {required int maxGraphemes, required int maxUtf8Bytes}) { 364 + final text = value?.trim(); 365 + if (text == null || text.isEmpty) { 366 + return; 367 + } 368 + if (text.characters.length > maxGraphemes || utf8.encode(text).length > maxUtf8Bytes) { 369 + throw ArgumentError('$field exceeds the profile lexicon limit.'); 370 + } 371 + } 372 + 373 + void _validateProfileImage(String field, ProfileImageUpload? upload) { 374 + if (upload == null) { 375 + return; 376 + } 377 + if (!ProfileImageUpload.acceptedMimeTypes.contains(upload.mimeType)) { 378 + throw ArgumentError('$field must be a JPEG or PNG image.'); 379 + } 380 + if (upload.bytes.length > ProfileImageUpload.maxBytes) { 381 + throw ArgumentError('$field must be smaller than 1MB.'); 382 + } 383 + } 384 + 296 385 Future<ProfileViewDetailed?> _getCachedProfile(String actor) async { 297 386 final cachedProfileByDid = await (_database.select( 298 387 _database.cachedProfiles, ··· 456 545 final ProfileView subject; 457 546 final List<ProfileView> profiles; 458 547 final String? cursor; 548 + } 549 + 550 + class ProfileEditDraft { 551 + const ProfileEditDraft({this.displayName, this.description, this.pronouns, this.website, this.avatar, this.banner}); 552 + 553 + final String? displayName; 554 + final String? description; 555 + final String? pronouns; 556 + final String? website; 557 + final ProfileImageUpload? avatar; 558 + final ProfileImageUpload? banner; 559 + } 560 + 561 + class ProfileImageUpload { 562 + const ProfileImageUpload({required this.bytes, required this.mimeType}); 563 + 564 + static const int maxBytes = 1000000; 565 + static const Set<String> acceptedMimeTypes = {'image/jpeg', 'image/png'}; 566 + 567 + final List<int> bytes; 568 + final String mimeType; 459 569 } 460 570 461 571 class ProfileActorLikesResult {
+462
lib/features/profile/presentation/profile_edit_screen.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:flutter/foundation.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_animate/flutter_animate.dart'; 7 + import 'package:flutter_bloc/flutter_bloc.dart'; 8 + import 'package:go_router/go_router.dart'; 9 + import 'package:image_picker/image_picker.dart'; 10 + import 'package:lazurite/core/logging/app_logger.dart'; 11 + import 'package:lazurite/core/theme/animation_tokens.dart'; 12 + import 'package:lazurite/core/theme/animation_utils.dart'; 13 + import 'package:lazurite/core/theme/color_filters.dart'; 14 + import 'package:lazurite/core/theme/theme_extensions.dart'; 15 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 16 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 17 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 18 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 20 + import 'package:lazurite/shared/utils/format_utils.dart'; 21 + 22 + class ProfileEditScreen extends StatefulWidget { 23 + const ProfileEditScreen({super.key}); 24 + 25 + @override 26 + State<ProfileEditScreen> createState() => _ProfileEditScreenState(); 27 + } 28 + 29 + class _ProfileEditScreenState extends State<ProfileEditScreen> { 30 + static const _avatarMaxWidth = 1000.0; 31 + static const _bannerMaxWidth = 2400.0; 32 + static const _bannerMaxHeight = 1200.0; 33 + 34 + final _formKey = GlobalKey<FormState>(); 35 + final _displayNameController = TextEditingController(); 36 + final _descriptionController = TextEditingController(); 37 + final _pronounsController = TextEditingController(); 38 + final _websiteController = TextEditingController(); 39 + final _picker = ImagePicker(); 40 + 41 + ProfileImageUpload? _avatarUpload; 42 + ProfileImageUpload? _bannerUpload; 43 + bool _saving = false; 44 + bool _pickingAvatar = false; 45 + bool _pickingBanner = false; 46 + String? _hydratedProfileDid; 47 + 48 + @override 49 + void dispose() { 50 + _displayNameController.dispose(); 51 + _descriptionController.dispose(); 52 + _pronounsController.dispose(); 53 + _websiteController.dispose(); 54 + super.dispose(); 55 + } 56 + 57 + void _hydrateFromProfile(ProfileViewDetailed profile) { 58 + if (_hydratedProfileDid == profile.did) { 59 + return; 60 + } 61 + _hydratedProfileDid = profile.did; 62 + _displayNameController.text = profile.displayName ?? ''; 63 + _descriptionController.text = profile.description ?? ''; 64 + _pronounsController.text = profile.pronouns ?? ''; 65 + _websiteController.text = profile.website ?? ''; 66 + } 67 + 68 + Future<void> _pickProfileImage({required bool banner}) async { 69 + if (banner ? _pickingBanner : _pickingAvatar) { 70 + return; 71 + } 72 + setState(() { 73 + if (banner) { 74 + _pickingBanner = true; 75 + } else { 76 + _pickingAvatar = true; 77 + } 78 + }); 79 + 80 + try { 81 + final image = await _picker.pickImage( 82 + source: ImageSource.gallery, 83 + maxWidth: banner ? _bannerMaxWidth : _avatarMaxWidth, 84 + maxHeight: banner ? _bannerMaxHeight : _avatarMaxWidth, 85 + imageQuality: 85, 86 + ); 87 + if (image == null) { 88 + return; 89 + } 90 + 91 + final size = await image.length(); 92 + if (size > ProfileImageUpload.maxBytes) { 93 + if (mounted) { 94 + showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); 95 + } 96 + return; 97 + } 98 + 99 + final mimeType = profileImageMimeTypeFor(reportedMimeType: image.mimeType, path: image.path); 100 + if (mimeType == null) { 101 + if (mounted) { 102 + showAppSnackBar(context, 'Use a JPEG or PNG image', isError: true); 103 + } 104 + return; 105 + } 106 + 107 + final bytes = await image.readAsBytes(); 108 + if (!mounted) { 109 + return; 110 + } 111 + setState(() { 112 + final upload = ProfileImageUpload(bytes: bytes, mimeType: mimeType); 113 + if (banner) { 114 + _bannerUpload = upload; 115 + } else { 116 + _avatarUpload = upload; 117 + } 118 + }); 119 + } catch (error, stackTrace) { 120 + log.w('ProfileEditScreen: failed to pick profile image', error: error, stackTrace: stackTrace); 121 + if (mounted) { 122 + showAppSnackBar(context, 'Unable to read selected image', isError: true); 123 + } 124 + } finally { 125 + if (mounted) { 126 + setState(() { 127 + if (banner) { 128 + _pickingBanner = false; 129 + } else { 130 + _pickingAvatar = false; 131 + } 132 + }); 133 + } 134 + } 135 + } 136 + 137 + Future<void> _save(ProfileViewDetailed profile) async { 138 + if (_saving) { 139 + return; 140 + } 141 + if (!(_formKey.currentState?.validate() ?? false)) { 142 + return; 143 + } 144 + 145 + final did = context.read<AuthBloc>().state.tokens?.did ?? profile.did; 146 + setState(() => _saving = true); 147 + try { 148 + await context.read<ProfileRepository>().updateProfile( 149 + did: did, 150 + draft: ProfileEditDraft( 151 + displayName: _optionalText(_displayNameController.text), 152 + description: _optionalText(_descriptionController.text), 153 + pronouns: _optionalText(_pronounsController.text), 154 + website: _normalizedWebsite(_websiteController.text), 155 + avatar: _avatarUpload, 156 + banner: _bannerUpload, 157 + ), 158 + ); 159 + if (!mounted) { 160 + return; 161 + } 162 + context.read<ProfileBloc>().add(ProfileLoadRequested(actor: did)); 163 + showAppSnackBar(context, 'Profile updated', behavior: SnackBarBehavior.floating); 164 + context.go('/profile/me'); 165 + } catch (error, stackTrace) { 166 + log.w('ProfileEditScreen: failed to save profile', error: error, stackTrace: stackTrace); 167 + if (mounted) { 168 + showAppSnackBar(context, 'Unable to update profile', isError: true); 169 + } 170 + } finally { 171 + if (mounted) { 172 + setState(() => _saving = false); 173 + } 174 + } 175 + } 176 + 177 + String? _optionalText(String value) { 178 + final trimmed = value.trim(); 179 + return trimmed.isEmpty ? null : trimmed; 180 + } 181 + 182 + String? _normalizedWebsite(String value) { 183 + final trimmed = value.trim(); 184 + if (trimmed.isEmpty) { 185 + return null; 186 + } 187 + final parsed = Uri.tryParse(trimmed); 188 + if (parsed != null && parsed.hasScheme) { 189 + return trimmed; 190 + } 191 + return 'https://$trimmed'; 192 + } 193 + 194 + String? _validateTextLimit(String? value, String label, {required int maxGraphemes, required int maxUtf8Bytes}) { 195 + final text = value?.trim() ?? ''; 196 + if (text.isEmpty) { 197 + return null; 198 + } 199 + if (text.characters.length > maxGraphemes) { 200 + return '$label must be $maxGraphemes characters or fewer'; 201 + } 202 + if (utf8.encode(text).length > maxUtf8Bytes) { 203 + return '$label is too long'; 204 + } 205 + return null; 206 + } 207 + 208 + String? _validateWebsite(String? value) { 209 + final normalized = _normalizedWebsite(value ?? ''); 210 + if (normalized == null) { 211 + return null; 212 + } 213 + final uri = Uri.tryParse(normalized); 214 + if (uri == null || !uri.hasScheme || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https')) { 215 + return 'Enter a valid website'; 216 + } 217 + return null; 218 + } 219 + 220 + @override 221 + Widget build(BuildContext context) { 222 + final profile = context.watch<ProfileBloc>().state.profile; 223 + if (profile != null) { 224 + _hydrateFromProfile(profile); 225 + } 226 + 227 + return AppScreenEntrance( 228 + child: Scaffold( 229 + appBar: AppBar( 230 + title: const Text('Edit profile'), 231 + leading: IconButton( 232 + icon: const Icon(Icons.close), 233 + tooltip: 'Cancel', 234 + onPressed: _saving ? null : () => context.go('/profile/me'), 235 + ), 236 + actions: [ 237 + TextButton.icon( 238 + key: const ValueKey('profile_edit_save_button'), 239 + onPressed: profile == null || _saving ? null : () => _save(profile), 240 + icon: _saving 241 + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 242 + : const Icon(Icons.check), 243 + label: const Text('Save'), 244 + ), 245 + ], 246 + ), 247 + body: profile == null 248 + ? const Center(child: CircularProgressIndicator()) 249 + : SafeArea( 250 + child: Form( 251 + key: _formKey, 252 + child: ListView( 253 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), 254 + children: _animatedFormChildren(context, profile), 255 + ), 256 + ), 257 + ), 258 + ), 259 + ); 260 + } 261 + 262 + List<Widget> _animatedFormChildren(BuildContext context, ProfileViewDetailed profile) { 263 + final children = [_buildMediaEditor(context, profile), const SizedBox(height: 24), _buildTextFields()]; 264 + if (!animationsAllowed(context)) { 265 + return children; 266 + } 267 + return [ 268 + for (var i = 0; i < children.length; i++) 269 + children[i] 270 + .animate(delay: Anim.staggerFor(i)) 271 + .fadeIn(duration: Anim.normal, curve: Anim.enter) 272 + .slideY(begin: 0.04, end: 0, duration: Anim.normal, curve: Anim.enter), 273 + ]; 274 + } 275 + 276 + Widget _buildMediaEditor(BuildContext context, ProfileViewDetailed profile) { 277 + final colorScheme = context.colorScheme; 278 + return Column( 279 + crossAxisAlignment: CrossAxisAlignment.start, 280 + children: [ 281 + Tooltip( 282 + message: 'Change banner image', 283 + child: Semantics( 284 + label: 'Change banner image', 285 + button: true, 286 + child: AspectRatio( 287 + aspectRatio: 3, 288 + child: InkWell( 289 + key: const ValueKey('profile_edit_banner_picker'), 290 + onTap: _saving ? null : () => _pickProfileImage(banner: true), 291 + borderRadius: BorderRadius.circular(8), 292 + child: ClipRRect( 293 + borderRadius: BorderRadius.circular(8), 294 + child: Stack( 295 + fit: StackFit.expand, 296 + children: [ 297 + _buildBannerPreview(context, profile), 298 + ColoredBox(color: Colors.black.withValues(alpha: 0.24)), 299 + Center( 300 + child: FilledButton.tonalIcon( 301 + onPressed: _saving ? null : () => _pickProfileImage(banner: true), 302 + icon: _pickingBanner 303 + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 304 + : const Icon(Icons.image_outlined), 305 + label: const Text('Banner'), 306 + ), 307 + ), 308 + ], 309 + ), 310 + ), 311 + ), 312 + ), 313 + ), 314 + ), 315 + Transform.translate( 316 + offset: const Offset(0, -28), 317 + child: Padding( 318 + padding: const EdgeInsets.only(left: 16), 319 + child: Tooltip( 320 + message: 'Change avatar image', 321 + child: Semantics( 322 + label: 'Change avatar image', 323 + button: true, 324 + child: InkWell( 325 + key: const ValueKey('profile_edit_avatar_picker'), 326 + onTap: _saving ? null : () => _pickProfileImage(banner: false), 327 + borderRadius: BorderRadius.circular(12), 328 + child: Container( 329 + width: 96, 330 + height: 96, 331 + decoration: BoxDecoration( 332 + color: colorScheme.surfaceContainerHighest, 333 + borderRadius: BorderRadius.circular(12), 334 + border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), 335 + ), 336 + clipBehavior: Clip.antiAlias, 337 + child: Stack( 338 + fit: StackFit.expand, 339 + children: [ 340 + _buildAvatarPreview(context, profile), 341 + ColoredBox(color: Colors.black.withValues(alpha: 0.24)), 342 + Center( 343 + child: _pickingAvatar 344 + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) 345 + : const Icon(Icons.photo_camera_outlined, color: Colors.white), 346 + ), 347 + ], 348 + ), 349 + ), 350 + ), 351 + ), 352 + ), 353 + ), 354 + ), 355 + ], 356 + ); 357 + } 358 + 359 + Widget _buildBannerPreview(BuildContext context, ProfileViewDetailed profile) { 360 + final upload = _bannerUpload; 361 + if (upload != null) { 362 + return Image.memory(Uint8List.fromList(upload.bytes), fit: BoxFit.cover); 363 + } 364 + if (profile.banner != null) { 365 + return ColorFiltered( 366 + colorFilter: AppColorFilters.greyscale, 367 + child: Image.network( 368 + profile.banner!, 369 + fit: BoxFit.cover, 370 + errorBuilder: (_, _, _) => ColoredBox(color: context.colorScheme.surfaceContainerHigh), 371 + ), 372 + ); 373 + } 374 + return ColoredBox(color: context.colorScheme.surfaceContainerHigh); 375 + } 376 + 377 + Widget _buildAvatarPreview(BuildContext context, ProfileViewDetailed profile) { 378 + final upload = _avatarUpload; 379 + if (upload != null) { 380 + return Image.memory(Uint8List.fromList(upload.bytes), fit: BoxFit.cover); 381 + } 382 + if (profile.avatar != null) { 383 + return Image.network( 384 + profile.avatar!, 385 + fit: BoxFit.cover, 386 + errorBuilder: (_, _, _) => _buildAvatarFallback(context, profile), 387 + ); 388 + } 389 + return _buildAvatarFallback(context, profile); 390 + } 391 + 392 + Widget _buildAvatarFallback(BuildContext context, ProfileViewDetailed profile) { 393 + return Center( 394 + child: Text( 395 + formatInitials(profile.displayName ?? profile.handle), 396 + style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), 397 + ), 398 + ); 399 + } 400 + 401 + Widget _buildTextFields() => Column( 402 + children: [ 403 + TextFormField( 404 + key: const ValueKey('profile_edit_display_name_field'), 405 + controller: _displayNameController, 406 + textInputAction: TextInputAction.next, 407 + decoration: const InputDecoration(labelText: 'Display name', prefixIcon: Icon(Icons.badge_outlined)), 408 + validator: (value) => _validateTextLimit(value, 'Display name', maxGraphemes: 64, maxUtf8Bytes: 640), 409 + ), 410 + const SizedBox(height: 16), 411 + TextFormField( 412 + key: const ValueKey('profile_edit_description_field'), 413 + controller: _descriptionController, 414 + minLines: 3, 415 + maxLines: 6, 416 + textInputAction: TextInputAction.newline, 417 + decoration: const InputDecoration(labelText: 'Description', prefixIcon: Icon(Icons.notes_outlined)), 418 + validator: (value) => _validateTextLimit(value, 'Description', maxGraphemes: 256, maxUtf8Bytes: 2560), 419 + ), 420 + const SizedBox(height: 16), 421 + TextFormField( 422 + key: const ValueKey('profile_edit_pronouns_field'), 423 + controller: _pronounsController, 424 + textInputAction: TextInputAction.next, 425 + decoration: const InputDecoration(labelText: 'Pronouns', prefixIcon: Icon(Icons.record_voice_over_outlined)), 426 + validator: (value) => _validateTextLimit(value, 'Pronouns', maxGraphemes: 20, maxUtf8Bytes: 200), 427 + ), 428 + const SizedBox(height: 16), 429 + TextFormField( 430 + key: const ValueKey('profile_edit_website_field'), 431 + controller: _websiteController, 432 + keyboardType: TextInputType.url, 433 + textInputAction: TextInputAction.done, 434 + decoration: const InputDecoration(labelText: 'Website', prefixIcon: Icon(Icons.link_outlined)), 435 + validator: _validateWebsite, 436 + onFieldSubmitted: (_) { 437 + final profile = context.read<ProfileBloc>().state.profile; 438 + if (profile != null) { 439 + _save(profile); 440 + } 441 + }, 442 + ), 443 + ], 444 + ); 445 + } 446 + 447 + @visibleForTesting 448 + String? profileImageMimeTypeFor({required String? reportedMimeType, required String path}) { 449 + final normalizedMimeType = reportedMimeType?.trim().toLowerCase(); 450 + if (normalizedMimeType != null && normalizedMimeType.isNotEmpty) { 451 + return ProfileImageUpload.acceptedMimeTypes.contains(normalizedMimeType) ? normalizedMimeType : null; 452 + } 453 + 454 + final normalizedPath = path.toLowerCase(); 455 + if (normalizedPath.endsWith('.png')) { 456 + return 'image/png'; 457 + } 458 + if (normalizedPath.endsWith('.jpg') || normalizedPath.endsWith('.jpeg')) { 459 + return 'image/jpeg'; 460 + } 461 + return null; 462 + }
+45 -7
lib/features/profile/presentation/profile_screen.dart
··· 585 585 actions: [ 586 586 if (actorScopedProfile != null && isOwnProfile) 587 587 IconButton( 588 + key: const Key('profile_edit_header_button'), 589 + icon: const Icon(Icons.edit_outlined), 590 + tooltip: 'Edit profile', 591 + onPressed: () => context.push('/profile/me/edit'), 592 + ), 593 + if (actorScopedProfile != null && isOwnProfile) 594 + IconButton( 588 595 key: const Key('profile_more_button'), 589 596 icon: const Icon(Icons.more_vert), 590 597 onPressed: () => _showOwnProfileMoreOptions(context, actorScopedProfile), ··· 787 794 final profileUi = 788 795 moderationService?.profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.profileView) ?? 789 796 const bsky_moderation.ModerationUI(); 797 + final pronouns = profile.pronouns?.trim(); 790 798 791 799 final metaChildren = <Widget>[ 792 - if (profile.pronouns?.isNotEmpty ?? false) 793 - _buildMetaChip(context, Icons.record_voice_over_outlined, profile.pronouns!), 794 800 if (profile.website?.isNotEmpty ?? false) 795 - _buildMetaChip(context, Icons.link_outlined, profile.website!, onTap: () => _launchWebsite(profile.website!)), 801 + _buildMetaChip( 802 + context, 803 + Icons.link_outlined, 804 + profile.website!, 805 + trailingIcon: Icons.open_in_new, 806 + onTap: () => _launchWebsite(profile.website!), 807 + ), 796 808 if (profile.createdAt != null) 797 809 _buildMetaChip( 798 810 context, ··· 806 818 child: Column( 807 819 crossAxisAlignment: CrossAxisAlignment.start, 808 820 children: [ 809 - Text( 810 - (profile.displayName ?? profile.handle).toUpperCase(), 811 - style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.5), 821 + Wrap( 822 + key: const ValueKey('profile_name_pronouns_wrap'), 823 + crossAxisAlignment: WrapCrossAlignment.center, 824 + spacing: 10, 825 + runSpacing: 4, 826 + children: [ 827 + Text( 828 + (profile.displayName ?? profile.handle).toUpperCase(), 829 + style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0), 830 + ), 831 + if (pronouns != null && pronouns.isNotEmpty) 832 + Text( 833 + pronouns, 834 + style: textTheme.labelLarge?.copyWith( 835 + color: colorScheme.onSurfaceVariant, 836 + fontWeight: FontWeight.w600, 837 + ), 838 + ), 839 + ], 812 840 ), 813 841 const SizedBox(height: 4), 814 842 ··· 880 908 ); 881 909 } 882 910 883 - Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { 911 + Widget _buildMetaChip( 912 + BuildContext context, 913 + IconData icon, 914 + String label, { 915 + IconData? trailingIcon, 916 + VoidCallback? onTap, 917 + }) { 884 918 final chip = Container( 885 919 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 886 920 decoration: BoxDecoration( ··· 898 932 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 899 933 ), 900 934 ), 935 + if (trailingIcon != null) ...[ 936 + const SizedBox(width: 6), 937 + Icon(trailingIcon, size: 14, color: context.colorScheme.onSurfaceVariant), 938 + ], 901 939 ], 902 940 ), 903 941 );
+15
test/core/router/app_router_test.dart
··· 310 310 expect(find.text('Search @me.bsky.social'), findsOneWidget); 311 311 }); 312 312 313 + testWidgets('profile edit route builds the own profile edit screen', (tester) async { 314 + await tester.binding.setSurfaceSize(const Size(430, 932)); 315 + addTearDown(() => tester.binding.setSurfaceSize(null)); 316 + final router = AppRouter(authBloc: authBloc).router; 317 + 318 + await tester.pumpWidget(buildSubjectWithRouter(router)); 319 + router.go('/profile/me/edit'); 320 + await tester.pumpAndSettle(); 321 + 322 + expect(find.text('Edit profile'), findsOneWidget); 323 + expect(find.byKey(const ValueKey('profile_edit_save_button')), findsOneWidget); 324 + 325 + router.dispose(); 326 + }); 327 + 313 328 testWidgets('opens profile connections route with requested initial tab', (tester) async { 314 329 await tester.binding.setSurfaceSize(const Size(430, 932)); 315 330 addTearDown(() => tester.binding.setSurfaceSize(null));
+224 -1
test/features/profile/data/profile_repository_test.dart
··· 1 1 import 'dart:convert'; 2 + import 'dart:typed_data'; 2 3 3 4 import 'package:atproto_core/atproto_core.dart' as atp_core; 4 5 import 'package:bluesky/app_bsky_actor_defs.dart'; ··· 217 218 expect(profiles.length, 26); 218 219 expect(profiles.map((p) => p.did), orderedEquals(actors)); 219 220 }); 221 + 222 + test('updates editable profile fields while preserving existing record fields', () async { 223 + final repo = _FakeRepoService( 224 + record: { 225 + r'$type': 'app.bsky.actor.profile', 226 + 'displayName': 'Old Name', 227 + 'description': 'Old description', 228 + 'labels': {r'$type': 'com.atproto.label.defs#selfLabels', 'values': []}, 229 + 'createdAt': '2026-01-01T00:00:00.000Z', 230 + }, 231 + cid: 'bafy-current', 232 + ); 233 + final repository = ProfileRepository( 234 + database: database, 235 + bluesky: _FakeBlueskyClient( 236 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 237 + atproto: _FakeAtprotoService(repo: repo), 238 + ), 239 + ); 240 + 241 + await repository.updateProfile( 242 + did: 'did:plc:alice', 243 + draft: const ProfileEditDraft( 244 + displayName: 'Alice Updated', 245 + description: 'New description', 246 + pronouns: 'she/her', 247 + website: 'https://alice.example', 248 + ), 249 + ); 250 + 251 + expect(repo.putRecords, hasLength(1)); 252 + final put = repo.putRecords.single; 253 + expect(put.repo, 'did:plc:alice'); 254 + expect(put.collection, 'app.bsky.actor.profile'); 255 + expect(put.rkey, 'self'); 256 + expect(put.validate, isTrue); 257 + expect(put.swapRecord, 'bafy-current'); 258 + expect(put.record['displayName'], 'Alice Updated'); 259 + expect(put.record['description'], 'New description'); 260 + expect(put.record['pronouns'], 'she/her'); 261 + expect(put.record['website'], 'https://alice.example'); 262 + expect(put.record['labels'], isA<Map>()); 263 + expect(put.record['createdAt'], '2026-01-01T00:00:00.000Z'); 264 + }); 265 + 266 + test('removes emptied optional text fields and uploads selected profile images', () async { 267 + final repo = _FakeRepoService( 268 + record: { 269 + r'$type': 'app.bsky.actor.profile', 270 + 'displayName': 'Old Name', 271 + 'description': 'Old description', 272 + 'pronouns': 'they/them', 273 + 'website': 'https://old.example', 274 + 'avatar': { 275 + r'$type': 'blob', 276 + 'mimeType': 'image/jpeg', 277 + 'size': 4, 278 + 'ref': {r'$link': 'old-avatar'}, 279 + }, 280 + }, 281 + cid: 'bafy-current', 282 + ); 283 + final repository = ProfileRepository( 284 + database: database, 285 + bluesky: _FakeBlueskyClient( 286 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 287 + atproto: _FakeAtprotoService(repo: repo), 288 + ), 289 + ); 290 + 291 + await repository.updateProfile( 292 + did: 'did:plc:alice', 293 + draft: const ProfileEditDraft( 294 + displayName: '', 295 + description: '', 296 + pronouns: '', 297 + website: '', 298 + avatar: ProfileImageUpload(bytes: [1, 2, 3], mimeType: 'image/png'), 299 + banner: ProfileImageUpload(bytes: [4, 5], mimeType: 'image/jpeg'), 300 + ), 301 + ); 302 + 303 + final record = repo.putRecords.single.record; 304 + expect(record.containsKey('displayName'), isFalse); 305 + expect(record.containsKey('description'), isFalse); 306 + expect(record.containsKey('pronouns'), isFalse); 307 + expect(record.containsKey('website'), isFalse); 308 + expect((record['avatar'] as Map)['mimeType'], 'image/png'); 309 + expect(((record['avatar'] as Map)['ref'] as Map)[r'$link'], 'uploaded-1'); 310 + expect((record['banner'] as Map)['mimeType'], 'image/jpeg'); 311 + expect(((record['banner'] as Map)['ref'] as Map)[r'$link'], 'uploaded-2'); 312 + expect(repo.uploadContentTypes, ['image/png', 'image/jpeg']); 313 + }); 314 + 315 + test('rejects profile edit values outside the profile lexicon limits', () async { 316 + final repo = _FakeRepoService(record: const {r'$type': 'app.bsky.actor.profile'}); 317 + final repository = ProfileRepository( 318 + database: database, 319 + bluesky: _FakeBlueskyClient( 320 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 321 + atproto: _FakeAtprotoService(repo: repo), 322 + ), 323 + ); 324 + 325 + expect( 326 + () => repository.updateProfile( 327 + did: 'did:plc:alice', 328 + draft: ProfileEditDraft(displayName: List.filled(65, 'A').join()), 329 + ), 330 + throwsArgumentError, 331 + ); 332 + expect(repo.putRecords, isEmpty); 333 + }); 220 334 }); 221 335 } 222 336 ··· 234 348 } 235 349 236 350 class _FakeBlueskyClient { 237 - _FakeBlueskyClient({required this.actor, _FakeGraphService? graph}) : graph = graph ?? _FakeGraphService(); 351 + _FakeBlueskyClient({required this.actor, _FakeGraphService? graph, _FakeAtprotoService? atproto}) 352 + : graph = graph ?? _FakeGraphService(), 353 + atproto = atproto ?? _FakeAtprotoService(repo: _FakeRepoService(record: const {})); 238 354 239 355 final _FakeActorService actor; 240 356 final _FakeGraphService graph; 357 + final _FakeAtprotoService atproto; 241 358 } 242 359 243 360 class _FakeActorService { ··· 277 394 const _FakeProfilesData(this.profiles); 278 395 279 396 final List<ProfileView> profiles; 397 + } 398 + 399 + class _FakeAtprotoService { 400 + const _FakeAtprotoService({required this.repo}); 401 + 402 + final _FakeRepoService repo; 403 + } 404 + 405 + class _FakeRepoService { 406 + _FakeRepoService({required Map<String, dynamic> record, this.cid = 'bafy-record'}) : _record = record; 407 + 408 + final Map<String, dynamic> _record; 409 + final String? cid; 410 + final putRecords = <_FakePutRecordCall>[]; 411 + final uploadContentTypes = <String>[]; 412 + 413 + Future<_FakeResponse<_FakeGetRecordData>> getRecord({ 414 + required String repo, 415 + required String collection, 416 + required String rkey, 417 + String? cid, 418 + String? $service, 419 + Map<String, String>? $headers, 420 + Map<String, String>? $unknown, 421 + }) async { 422 + return _FakeResponse(_FakeGetRecordData(value: _record, cid: this.cid)); 423 + } 424 + 425 + Future<_FakeResponse<_FakePutRecordData>> putRecord({ 426 + required String repo, 427 + required String collection, 428 + required String rkey, 429 + bool? validate, 430 + required Map<String, dynamic> record, 431 + String? swapRecord, 432 + String? swapCommit, 433 + String? $service, 434 + Map<String, String>? $headers, 435 + Map<String, String>? $unknown, 436 + }) async { 437 + putRecords.add( 438 + _FakePutRecordCall( 439 + repo: repo, 440 + collection: collection, 441 + rkey: rkey, 442 + validate: validate, 443 + record: Map<String, dynamic>.from(record), 444 + swapRecord: swapRecord, 445 + ), 446 + ); 447 + return _FakeResponse(const _FakePutRecordData()); 448 + } 449 + 450 + Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ 451 + required Uint8List bytes, 452 + String? $service, 453 + Map<String, String>? $headers, 454 + Map<String, String>? $parameters, 455 + }) async { 456 + final contentType = $headers?['Content-Type'] ?? 'image/jpeg'; 457 + uploadContentTypes.add(contentType); 458 + return _FakeResponse( 459 + _FakeUploadBlobData( 460 + atp_core.Blob( 461 + mimeType: contentType, 462 + size: bytes.length, 463 + ref: atp_core.BlobRef(link: 'uploaded-${uploadContentTypes.length}'), 464 + ), 465 + ), 466 + ); 467 + } 468 + } 469 + 470 + class _FakeGetRecordData { 471 + const _FakeGetRecordData({required this.value, this.cid}); 472 + 473 + final Map<String, dynamic> value; 474 + final String? cid; 475 + } 476 + 477 + class _FakePutRecordData { 478 + const _FakePutRecordData(); 479 + } 480 + 481 + class _FakeUploadBlobData { 482 + const _FakeUploadBlobData(this.blob); 483 + 484 + final atp_core.Blob blob; 485 + } 486 + 487 + class _FakePutRecordCall { 488 + const _FakePutRecordCall({ 489 + required this.repo, 490 + required this.collection, 491 + required this.rkey, 492 + required this.validate, 493 + required this.record, 494 + required this.swapRecord, 495 + }); 496 + 497 + final String repo; 498 + final String collection; 499 + final String rkey; 500 + final bool? validate; 501 + final Map<String, dynamic> record; 502 + final String? swapRecord; 280 503 } 281 504 282 505 class _FakeGraphService {
+204
test/features/profile/presentation/profile_edit_screen_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/theme/app_theme.dart'; 8 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 11 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 12 + import 'package:lazurite/features/profile/presentation/profile_edit_screen.dart'; 13 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 14 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 15 + import 'package:mocktail/mocktail.dart'; 16 + 17 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 18 + 19 + class MockProfileBloc extends MockBloc<ProfileEvent, ProfileState> implements ProfileBloc {} 20 + 21 + class MockProfileRepository extends Mock implements ProfileRepository {} 22 + 23 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 24 + 25 + class _ProfileEditDraftFake extends Fake implements ProfileEditDraft {} 26 + 27 + void main() { 28 + late MockAuthBloc authBloc; 29 + late MockProfileBloc profileBloc; 30 + late MockProfileRepository profileRepository; 31 + late MockSettingsCubit settingsCubit; 32 + 33 + const tokens = AuthTokens( 34 + accessToken: 'access', 35 + refreshToken: 'refresh', 36 + did: 'did:plc:me', 37 + handle: 'me.bsky.social', 38 + ); 39 + 40 + const profile = ProfileViewDetailed( 41 + did: 'did:plc:me', 42 + handle: 'me.bsky.social', 43 + displayName: 'River Tam', 44 + description: 'Signal and signal boost.', 45 + pronouns: 'she/her', 46 + website: 'river.example', 47 + avatar: 'https://example.com/avatar.jpg', 48 + banner: 'https://example.com/banner.jpg', 49 + ); 50 + 51 + const settingsState = SettingsState( 52 + themePalette: AppThemePalette.oxocarbon, 53 + themeVariant: AppThemeVariant.dark, 54 + useSystemTheme: false, 55 + ); 56 + 57 + setUpAll(() { 58 + registerFallbackValue(_ProfileEditDraftFake()); 59 + }); 60 + 61 + setUp(() { 62 + authBloc = MockAuthBloc(); 63 + profileBloc = MockProfileBloc(); 64 + profileRepository = MockProfileRepository(); 65 + settingsCubit = MockSettingsCubit(); 66 + 67 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 68 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: profile)); 69 + when(() => settingsCubit.state).thenReturn(settingsState); 70 + when( 71 + () => profileRepository.updateProfile( 72 + did: any(named: 'did'), 73 + draft: any(named: 'draft'), 74 + ), 75 + ).thenAnswer((_) async {}); 76 + 77 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 78 + whenListen( 79 + profileBloc, 80 + const Stream<ProfileState>.empty(), 81 + initialState: const ProfileState.loaded(profile: profile), 82 + ); 83 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: settingsState); 84 + }); 85 + 86 + Widget buildSubject(GoRouter router) { 87 + return MultiBlocProvider( 88 + providers: [ 89 + BlocProvider<AuthBloc>.value(value: authBloc), 90 + BlocProvider<ProfileBloc>.value(value: profileBloc), 91 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 92 + ], 93 + child: RepositoryProvider<ProfileRepository>.value( 94 + value: profileRepository, 95 + child: MaterialApp.router(routerConfig: router), 96 + ), 97 + ); 98 + } 99 + 100 + GoRouter buildRouter() { 101 + return GoRouter( 102 + initialLocation: '/profile/me/edit', 103 + routes: [ 104 + GoRoute( 105 + path: '/profile/me', 106 + builder: (_, _) => const Scaffold(body: Text('profile-me')), 107 + ), 108 + GoRoute(path: '/profile/me/edit', builder: (_, _) => const ProfileEditScreen()), 109 + ], 110 + ); 111 + } 112 + 113 + testWidgets('hydrates the profile edit form from the loaded profile', (tester) async { 114 + final router = buildRouter(); 115 + 116 + await tester.pumpWidget(buildSubject(router)); 117 + await tester.pumpAndSettle(); 118 + 119 + expect(find.text('Edit profile'), findsOneWidget); 120 + expect( 121 + tester.widget<TextFormField>(find.byKey(const ValueKey('profile_edit_display_name_field'))).controller?.text, 122 + 'River Tam', 123 + ); 124 + expect( 125 + tester.widget<TextFormField>(find.byKey(const ValueKey('profile_edit_description_field'))).controller?.text, 126 + 'Signal and signal boost.', 127 + ); 128 + expect( 129 + tester.widget<TextFormField>(find.byKey(const ValueKey('profile_edit_pronouns_field'))).controller?.text, 130 + 'she/her', 131 + ); 132 + expect( 133 + tester.widget<TextFormField>(find.byKey(const ValueKey('profile_edit_website_field'))).controller?.text, 134 + 'river.example', 135 + ); 136 + expect(find.byTooltip('Change avatar image'), findsOneWidget); 137 + expect(find.byTooltip('Change banner image'), findsOneWidget); 138 + 139 + router.dispose(); 140 + }); 141 + 142 + test('profile image MIME resolver accepts only JPEG and PNG', () { 143 + expect(profileImageMimeTypeFor(reportedMimeType: 'image/png', path: 'anything'), 'image/png'); 144 + expect(profileImageMimeTypeFor(reportedMimeType: 'image/jpeg', path: 'anything'), 'image/jpeg'); 145 + expect(profileImageMimeTypeFor(reportedMimeType: null, path: '/tmp/avatar.jpg'), 'image/jpeg'); 146 + expect(profileImageMimeTypeFor(reportedMimeType: null, path: '/tmp/avatar.jpeg'), 'image/jpeg'); 147 + expect(profileImageMimeTypeFor(reportedMimeType: null, path: '/tmp/avatar.png'), 'image/png'); 148 + expect(profileImageMimeTypeFor(reportedMimeType: null, path: '/tmp/avatar.gif'), isNull); 149 + expect(profileImageMimeTypeFor(reportedMimeType: null, path: '/tmp/avatar.webp'), isNull); 150 + expect(profileImageMimeTypeFor(reportedMimeType: 'image/gif', path: '/tmp/avatar.jpg'), isNull); 151 + }); 152 + 153 + testWidgets('saves normalized profile edits and returns to own profile', (tester) async { 154 + final router = buildRouter(); 155 + 156 + await tester.pumpWidget(buildSubject(router)); 157 + await tester.pumpAndSettle(); 158 + 159 + await tester.enterText(find.byKey(const ValueKey('profile_edit_display_name_field')), 'Serenity'); 160 + await tester.enterText(find.byKey(const ValueKey('profile_edit_description_field')), 'New bio'); 161 + await tester.enterText(find.byKey(const ValueKey('profile_edit_pronouns_field')), 'they/them'); 162 + await tester.enterText(find.byKey(const ValueKey('profile_edit_website_field')), 'serenity.example'); 163 + await tester.tap(find.byKey(const ValueKey('profile_edit_save_button'))); 164 + await tester.pumpAndSettle(); 165 + 166 + final captured = 167 + verify( 168 + () => profileRepository.updateProfile( 169 + did: 'did:plc:me', 170 + draft: captureAny(named: 'draft'), 171 + ), 172 + ).captured.single 173 + as ProfileEditDraft; 174 + expect(captured.displayName, 'Serenity'); 175 + expect(captured.description, 'New bio'); 176 + expect(captured.pronouns, 'they/them'); 177 + expect(captured.website, 'https://serenity.example'); 178 + verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); 179 + expect(find.text('profile-me'), findsOneWidget); 180 + 181 + router.dispose(); 182 + }); 183 + 184 + testWidgets('validates website input before saving', (tester) async { 185 + final router = buildRouter(); 186 + 187 + await tester.pumpWidget(buildSubject(router)); 188 + await tester.pumpAndSettle(); 189 + 190 + await tester.enterText(find.byKey(const ValueKey('profile_edit_website_field')), 'ftp://example.com'); 191 + await tester.tap(find.byKey(const ValueKey('profile_edit_save_button'))); 192 + await tester.pump(); 193 + 194 + expect(find.text('Enter a valid website'), findsOneWidget); 195 + verifyNever( 196 + () => profileRepository.updateProfile( 197 + did: any(named: 'did'), 198 + draft: any(named: 'draft'), 199 + ), 200 + ); 201 + 202 + router.dispose(); 203 + }); 204 + }
+44 -1
test/features/profile/presentation/profile_screen_test.dart
··· 169 169 findsOneWidget, 170 170 ); 171 171 expect(find.text('she/her'), findsOneWidget); 172 + final namePronounsWrap = find.byKey(const ValueKey('profile_name_pronouns_wrap')); 173 + expect(find.descendant(of: namePronounsWrap, matching: find.text('RIVER TAM')), findsOneWidget); 174 + expect(find.descendant(of: namePronounsWrap, matching: find.text('she/her')), findsOneWidget); 172 175 expect(find.text('river.example'), findsOneWidget); 176 + expect(find.byIcon(Icons.open_in_new), findsOneWidget); 173 177 expect(find.text('Joined March 2024'), findsOneWidget); 174 178 }); 175 179 ··· 180 184 expect(find.text('River Tam'), findsOneWidget); 181 185 }); 182 186 183 - testWidgets('shows separate Bookmarks and Liked buttons on own profile', (tester) async { 187 + testWidgets('shows own profile header edit action and shortcut buttons', (tester) async { 184 188 useLargeScreen(tester); 185 189 await tester.pumpWidget(buildSubject()); 186 190 191 + expect(find.byKey(const Key('profile_edit_header_button')), findsOneWidget); 192 + expect(find.text('Edit Profile'), findsNothing); 187 193 expect(find.text('Bookmarks'), findsOneWidget); 188 194 expect(find.text('Liked'), findsOneWidget); 195 + }); 196 + 197 + testWidgets('header edit profile action opens the profile edit route', (tester) async { 198 + useLargeScreen(tester); 199 + final router = GoRouter( 200 + routes: [ 201 + GoRoute( 202 + path: '/', 203 + builder: (context, state) => MultiBlocProvider( 204 + providers: [ 205 + BlocProvider<AuthBloc>.value(value: authBloc), 206 + BlocProvider<ProfileBloc>.value(value: profileBloc), 207 + BlocProvider<FeedBloc>.value(value: feedBloc), 208 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 209 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 210 + ], 211 + child: const ProfileScreen(), 212 + ), 213 + ), 214 + GoRoute( 215 + path: '/profile/me/edit', 216 + builder: (context, state) => const Scaffold(body: Text('edit-profile')), 217 + ), 218 + ], 219 + ); 220 + 221 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 222 + await tester.pumpAndSettle(); 223 + 224 + await tester.tap(find.byKey(const Key('profile_edit_header_button'))); 225 + await tester.pumpAndSettle(); 226 + 227 + expect(find.text('edit-profile'), findsOneWidget); 228 + 229 + router.dispose(); 189 230 }); 190 231 191 232 testWidgets('tapping following stat opens connections screen on following tab', (tester) async { ··· 256 297 257 298 await tester.pumpWidget(widget); 258 299 300 + expect(find.byKey(const Key('profile_edit_header_button')), findsNothing); 301 + expect(find.text('Edit Profile'), findsNothing); 259 302 expect(find.text('Bookmarks'), findsNothing); 260 303 expect(find.text('Liked'), findsNothing); 261 304 });