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.

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

This reverts commit 846d5d9c0fc1ad8242511b193e3c3f52135723f5.

+9 -1068
-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'; 62 61 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 63 62 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 64 63 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; ··· 537 536 path: 'connections', 538 537 pageBuilder: (context, state) => 539 538 _page(context, state, _buildProfileConnectionsRoute(context, state, context.read<String>())), 540 - ), 541 - GoRoute( 542 - path: 'edit', 543 - pageBuilder: (context, state) => _page(context, state, const ProfileEditScreen()), 544 539 ), 545 540 ], 546 541 ),
-110
lib/features/profile/data/profile_repository.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 - import 'dart:typed_data'; 4 3 5 4 import 'package:atproto_core/atproto_core.dart' as atp_core; 6 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 7 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 8 - import 'package:characters/characters.dart'; 9 7 import 'package:bluesky/bluesky.dart'; 10 8 import 'package:lazurite/core/database/app_database.dart'; 11 9 import 'package:lazurite/core/logging/app_logger.dart'; ··· 295 293 } 296 294 } 297 295 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 - 385 296 Future<ProfileViewDetailed?> _getCachedProfile(String actor) async { 386 297 final cachedProfileByDid = await (_database.select( 387 298 _database.cachedProfiles, ··· 545 456 final ProfileView subject; 546 457 final List<ProfileView> profiles; 547 458 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; 569 459 } 570 460 571 461 class ProfileActorLikesResult {
-435
lib/features/profile/presentation/profile_edit_screen.dart
··· 1 - import 'dart:convert'; 2 - import 'dart:io'; 3 - import 'dart:typed_data'; 4 - 5 - import 'package:bluesky/app_bsky_actor_defs.dart'; 6 - import 'package:flutter/material.dart'; 7 - import 'package:flutter_animate/flutter_animate.dart'; 8 - import 'package:flutter_bloc/flutter_bloc.dart'; 9 - import 'package:go_router/go_router.dart'; 10 - import 'package:image_picker/image_picker.dart'; 11 - import 'package:lazurite/core/logging/app_logger.dart'; 12 - import 'package:lazurite/core/theme/animation_tokens.dart'; 13 - import 'package:lazurite/core/theme/animation_utils.dart'; 14 - import 'package:lazurite/core/theme/color_filters.dart'; 15 - import 'package:lazurite/core/theme/theme_extensions.dart'; 16 - import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17 - import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 18 - import 'package:lazurite/features/profile/data/profile_repository.dart'; 19 - import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 - import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 21 - import 'package:lazurite/shared/utils/format_utils.dart'; 22 - 23 - class ProfileEditScreen extends StatefulWidget { 24 - const ProfileEditScreen({super.key}); 25 - 26 - @override 27 - State<ProfileEditScreen> createState() => _ProfileEditScreenState(); 28 - } 29 - 30 - class _ProfileEditScreenState extends State<ProfileEditScreen> { 31 - static const _avatarMaxWidth = 1000.0; 32 - static const _bannerMaxWidth = 2400.0; 33 - static const _bannerMaxHeight = 1200.0; 34 - 35 - final _formKey = GlobalKey<FormState>(); 36 - final _displayNameController = TextEditingController(); 37 - final _descriptionController = TextEditingController(); 38 - final _pronounsController = TextEditingController(); 39 - final _websiteController = TextEditingController(); 40 - final _picker = ImagePicker(); 41 - 42 - ProfileImageUpload? _avatarUpload; 43 - ProfileImageUpload? _bannerUpload; 44 - bool _saving = false; 45 - bool _pickingAvatar = false; 46 - bool _pickingBanner = false; 47 - String? _hydratedProfileDid; 48 - 49 - @override 50 - void dispose() { 51 - _displayNameController.dispose(); 52 - _descriptionController.dispose(); 53 - _pronounsController.dispose(); 54 - _websiteController.dispose(); 55 - super.dispose(); 56 - } 57 - 58 - void _hydrateFromProfile(ProfileViewDetailed profile) { 59 - if (_hydratedProfileDid == profile.did) { 60 - return; 61 - } 62 - _hydratedProfileDid = profile.did; 63 - _displayNameController.text = profile.displayName ?? ''; 64 - _descriptionController.text = profile.description ?? ''; 65 - _pronounsController.text = profile.pronouns ?? ''; 66 - _websiteController.text = profile.website ?? ''; 67 - } 68 - 69 - Future<void> _pickProfileImage({required bool banner}) async { 70 - if (banner ? _pickingBanner : _pickingAvatar) { 71 - return; 72 - } 73 - setState(() { 74 - if (banner) { 75 - _pickingBanner = true; 76 - } else { 77 - _pickingAvatar = true; 78 - } 79 - }); 80 - 81 - try { 82 - final image = await _picker.pickImage( 83 - source: ImageSource.gallery, 84 - maxWidth: banner ? _bannerMaxWidth : _avatarMaxWidth, 85 - maxHeight: banner ? _bannerMaxHeight : _avatarMaxWidth, 86 - imageQuality: 85, 87 - ); 88 - if (image == null) { 89 - return; 90 - } 91 - 92 - final file = File(image.path); 93 - final size = await file.length(); 94 - if (size > ProfileImageUpload.maxBytes) { 95 - if (mounted) { 96 - showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); 97 - } 98 - return; 99 - } 100 - 101 - final mimeType = _mimeTypeForPath(image.path); 102 - if (!ProfileImageUpload.acceptedMimeTypes.contains(mimeType)) { 103 - if (mounted) { 104 - showAppSnackBar(context, 'Use a JPEG or PNG image', isError: true); 105 - } 106 - return; 107 - } 108 - 109 - final bytes = await file.readAsBytes(); 110 - if (!mounted) { 111 - return; 112 - } 113 - setState(() { 114 - final upload = ProfileImageUpload(bytes: bytes, mimeType: mimeType); 115 - if (banner) { 116 - _bannerUpload = upload; 117 - } else { 118 - _avatarUpload = upload; 119 - } 120 - }); 121 - } catch (error, stackTrace) { 122 - log.w('ProfileEditScreen: failed to pick profile image', error: error, stackTrace: stackTrace); 123 - if (mounted) { 124 - showAppSnackBar(context, 'Unable to read selected image', isError: true); 125 - } 126 - } finally { 127 - if (mounted) { 128 - setState(() { 129 - if (banner) { 130 - _pickingBanner = false; 131 - } else { 132 - _pickingAvatar = false; 133 - } 134 - }); 135 - } 136 - } 137 - } 138 - 139 - String _mimeTypeForPath(String path) => path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; 140 - 141 - Future<void> _save(ProfileViewDetailed profile) async { 142 - if (_saving) { 143 - return; 144 - } 145 - if (!(_formKey.currentState?.validate() ?? false)) { 146 - return; 147 - } 148 - 149 - final did = context.read<AuthBloc>().state.tokens?.did ?? profile.did; 150 - setState(() => _saving = true); 151 - try { 152 - await context.read<ProfileRepository>().updateProfile( 153 - did: did, 154 - draft: ProfileEditDraft( 155 - displayName: _optionalText(_displayNameController.text), 156 - description: _optionalText(_descriptionController.text), 157 - pronouns: _optionalText(_pronounsController.text), 158 - website: _normalizedWebsite(_websiteController.text), 159 - avatar: _avatarUpload, 160 - banner: _bannerUpload, 161 - ), 162 - ); 163 - if (!mounted) { 164 - return; 165 - } 166 - context.read<ProfileBloc>().add(ProfileLoadRequested(actor: did)); 167 - showAppSnackBar(context, 'Profile updated', behavior: SnackBarBehavior.floating); 168 - context.go('/profile/me'); 169 - } catch (error, stackTrace) { 170 - log.w('ProfileEditScreen: failed to save profile', error: error, stackTrace: stackTrace); 171 - if (mounted) { 172 - showAppSnackBar(context, 'Unable to update profile', isError: true); 173 - } 174 - } finally { 175 - if (mounted) { 176 - setState(() => _saving = false); 177 - } 178 - } 179 - } 180 - 181 - String? _optionalText(String value) { 182 - final trimmed = value.trim(); 183 - return trimmed.isEmpty ? null : trimmed; 184 - } 185 - 186 - String? _normalizedWebsite(String value) { 187 - final trimmed = value.trim(); 188 - if (trimmed.isEmpty) { 189 - return null; 190 - } 191 - final parsed = Uri.tryParse(trimmed); 192 - if (parsed != null && parsed.hasScheme) { 193 - return trimmed; 194 - } 195 - return 'https://$trimmed'; 196 - } 197 - 198 - String? _validateTextLimit(String? value, String label, {required int maxGraphemes, required int maxUtf8Bytes}) { 199 - final text = value?.trim() ?? ''; 200 - if (text.isEmpty) { 201 - return null; 202 - } 203 - if (text.characters.length > maxGraphemes) { 204 - return '$label must be $maxGraphemes characters or fewer'; 205 - } 206 - if (utf8.encode(text).length > maxUtf8Bytes) { 207 - return '$label is too long'; 208 - } 209 - return null; 210 - } 211 - 212 - String? _validateWebsite(String? value) { 213 - final normalized = _normalizedWebsite(value ?? ''); 214 - if (normalized == null) { 215 - return null; 216 - } 217 - final uri = Uri.tryParse(normalized); 218 - if (uri == null || !uri.hasScheme || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https')) { 219 - return 'Enter a valid website'; 220 - } 221 - return null; 222 - } 223 - 224 - @override 225 - Widget build(BuildContext context) { 226 - final profile = context.watch<ProfileBloc>().state.profile; 227 - if (profile != null) { 228 - _hydrateFromProfile(profile); 229 - } 230 - 231 - return AppScreenEntrance( 232 - child: Scaffold( 233 - appBar: AppBar( 234 - title: const Text('Edit profile'), 235 - leading: IconButton( 236 - icon: const Icon(Icons.close), 237 - tooltip: 'Cancel', 238 - onPressed: _saving ? null : () => context.go('/profile/me'), 239 - ), 240 - actions: [ 241 - TextButton.icon( 242 - key: const ValueKey('profile_edit_save_button'), 243 - onPressed: profile == null || _saving ? null : () => _save(profile), 244 - icon: _saving 245 - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 246 - : const Icon(Icons.check), 247 - label: const Text('Save'), 248 - ), 249 - ], 250 - ), 251 - body: profile == null 252 - ? const Center(child: CircularProgressIndicator()) 253 - : SafeArea( 254 - child: Form( 255 - key: _formKey, 256 - child: ListView( 257 - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), 258 - children: _animatedFormChildren(context, profile), 259 - ), 260 - ), 261 - ), 262 - ), 263 - ); 264 - } 265 - 266 - List<Widget> _animatedFormChildren(BuildContext context, ProfileViewDetailed profile) { 267 - final children = [_buildMediaEditor(context, profile), const SizedBox(height: 24), _buildTextFields()]; 268 - if (!animationsAllowed(context)) { 269 - return children; 270 - } 271 - return [ 272 - for (var i = 0; i < children.length; i++) 273 - children[i] 274 - .animate(delay: Anim.staggerFor(i)) 275 - .fadeIn(duration: Anim.normal, curve: Anim.enter) 276 - .slideY(begin: 0.04, end: 0, duration: Anim.normal, curve: Anim.enter), 277 - ]; 278 - } 279 - 280 - Widget _buildMediaEditor(BuildContext context, ProfileViewDetailed profile) { 281 - final colorScheme = context.colorScheme; 282 - return Column( 283 - crossAxisAlignment: CrossAxisAlignment.start, 284 - children: [ 285 - AspectRatio( 286 - aspectRatio: 3, 287 - child: InkWell( 288 - key: const ValueKey('profile_edit_banner_picker'), 289 - onTap: _saving ? null : () => _pickProfileImage(banner: true), 290 - borderRadius: BorderRadius.circular(8), 291 - child: ClipRRect( 292 - borderRadius: BorderRadius.circular(8), 293 - child: Stack( 294 - fit: StackFit.expand, 295 - children: [ 296 - _buildBannerPreview(context, profile), 297 - ColoredBox(color: Colors.black.withValues(alpha: 0.24)), 298 - Center( 299 - child: FilledButton.tonalIcon( 300 - onPressed: _saving ? null : () => _pickProfileImage(banner: true), 301 - icon: _pickingBanner 302 - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 303 - : const Icon(Icons.image_outlined), 304 - label: const Text('Banner'), 305 - ), 306 - ), 307 - ], 308 - ), 309 - ), 310 - ), 311 - ), 312 - Transform.translate( 313 - offset: const Offset(0, -28), 314 - child: Padding( 315 - padding: const EdgeInsets.only(left: 16), 316 - child: InkWell( 317 - key: const ValueKey('profile_edit_avatar_picker'), 318 - onTap: _saving ? null : () => _pickProfileImage(banner: false), 319 - borderRadius: BorderRadius.circular(12), 320 - child: Container( 321 - width: 96, 322 - height: 96, 323 - decoration: BoxDecoration( 324 - color: colorScheme.surfaceContainerHighest, 325 - borderRadius: BorderRadius.circular(12), 326 - border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), 327 - ), 328 - clipBehavior: Clip.antiAlias, 329 - child: Stack( 330 - fit: StackFit.expand, 331 - children: [ 332 - _buildAvatarPreview(context, profile), 333 - ColoredBox(color: Colors.black.withValues(alpha: 0.24)), 334 - Center( 335 - child: _pickingAvatar 336 - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) 337 - : const Icon(Icons.photo_camera_outlined, color: Colors.white), 338 - ), 339 - ], 340 - ), 341 - ), 342 - ), 343 - ), 344 - ), 345 - ], 346 - ); 347 - } 348 - 349 - Widget _buildBannerPreview(BuildContext context, ProfileViewDetailed profile) { 350 - final upload = _bannerUpload; 351 - if (upload != null) { 352 - return Image.memory(Uint8List.fromList(upload.bytes), fit: BoxFit.cover); 353 - } 354 - if (profile.banner != null) { 355 - return ColorFiltered( 356 - colorFilter: AppColorFilters.greyscale, 357 - child: Image.network( 358 - profile.banner!, 359 - fit: BoxFit.cover, 360 - errorBuilder: (_, _, _) => ColoredBox(color: context.colorScheme.surfaceContainerHigh), 361 - ), 362 - ); 363 - } 364 - return ColoredBox(color: context.colorScheme.surfaceContainerHigh); 365 - } 366 - 367 - Widget _buildAvatarPreview(BuildContext context, ProfileViewDetailed profile) { 368 - final upload = _avatarUpload; 369 - if (upload != null) { 370 - return Image.memory(Uint8List.fromList(upload.bytes), fit: BoxFit.cover); 371 - } 372 - if (profile.avatar != null) { 373 - return Image.network( 374 - profile.avatar!, 375 - fit: BoxFit.cover, 376 - errorBuilder: (_, _, _) => _buildAvatarFallback(context, profile), 377 - ); 378 - } 379 - return _buildAvatarFallback(context, profile); 380 - } 381 - 382 - Widget _buildAvatarFallback(BuildContext context, ProfileViewDetailed profile) { 383 - return Center( 384 - child: Text( 385 - formatInitials(profile.displayName ?? profile.handle), 386 - style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), 387 - ), 388 - ); 389 - } 390 - 391 - Widget _buildTextFields() => Column( 392 - children: [ 393 - TextFormField( 394 - key: const ValueKey('profile_edit_display_name_field'), 395 - controller: _displayNameController, 396 - textInputAction: TextInputAction.next, 397 - decoration: const InputDecoration(labelText: 'Display name', prefixIcon: Icon(Icons.badge_outlined)), 398 - validator: (value) => _validateTextLimit(value, 'Display name', maxGraphemes: 64, maxUtf8Bytes: 640), 399 - ), 400 - const SizedBox(height: 16), 401 - TextFormField( 402 - key: const ValueKey('profile_edit_description_field'), 403 - controller: _descriptionController, 404 - minLines: 3, 405 - maxLines: 6, 406 - textInputAction: TextInputAction.newline, 407 - decoration: const InputDecoration(labelText: 'Description', prefixIcon: Icon(Icons.notes_outlined)), 408 - validator: (value) => _validateTextLimit(value, 'Description', maxGraphemes: 256, maxUtf8Bytes: 2560), 409 - ), 410 - const SizedBox(height: 16), 411 - TextFormField( 412 - key: const ValueKey('profile_edit_pronouns_field'), 413 - controller: _pronounsController, 414 - textInputAction: TextInputAction.next, 415 - decoration: const InputDecoration(labelText: 'Pronouns', prefixIcon: Icon(Icons.record_voice_over_outlined)), 416 - validator: (value) => _validateTextLimit(value, 'Pronouns', maxGraphemes: 20, maxUtf8Bytes: 200), 417 - ), 418 - const SizedBox(height: 16), 419 - TextFormField( 420 - key: const ValueKey('profile_edit_website_field'), 421 - controller: _websiteController, 422 - keyboardType: TextInputType.url, 423 - textInputAction: TextInputAction.done, 424 - decoration: const InputDecoration(labelText: 'Website', prefixIcon: Icon(Icons.link_outlined)), 425 - validator: _validateWebsite, 426 - onFieldSubmitted: (_) { 427 - final profile = context.read<ProfileBloc>().state.profile; 428 - if (profile != null) { 429 - _save(profile); 430 - } 431 - }, 432 - ), 433 - ], 434 - ); 435 - }
+7 -44
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( 595 588 key: const Key('profile_more_button'), 596 589 icon: const Icon(Icons.more_vert), 597 590 onPressed: () => _showOwnProfileMoreOptions(context, actorScopedProfile), ··· 794 787 final profileUi = 795 788 moderationService?.profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.profileView) ?? 796 789 const bsky_moderation.ModerationUI(); 797 - final pronouns = profile.pronouns?.trim(); 798 790 799 791 final metaChildren = <Widget>[ 792 + if (profile.pronouns?.isNotEmpty ?? false) 793 + _buildMetaChip(context, Icons.record_voice_over_outlined, profile.pronouns!), 800 794 if (profile.website?.isNotEmpty ?? false) 801 - _buildMetaChip( 802 - context, 803 - Icons.link_outlined, 804 - profile.website!, 805 - trailingIcon: Icons.open_in_new, 806 - onTap: () => _launchWebsite(profile.website!), 807 - ), 795 + _buildMetaChip(context, Icons.link_outlined, profile.website!, onTap: () => _launchWebsite(profile.website!)), 808 796 if (profile.createdAt != null) 809 797 _buildMetaChip( 810 798 context, ··· 818 806 child: Column( 819 807 crossAxisAlignment: CrossAxisAlignment.start, 820 808 children: [ 821 - Wrap( 822 - crossAxisAlignment: WrapCrossAlignment.center, 823 - spacing: 10, 824 - runSpacing: 4, 825 - children: [ 826 - Text( 827 - (profile.displayName ?? profile.handle).toUpperCase(), 828 - style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0), 829 - ), 830 - if (pronouns != null && pronouns.isNotEmpty) 831 - Text( 832 - pronouns, 833 - style: textTheme.labelLarge?.copyWith( 834 - color: colorScheme.onSurfaceVariant, 835 - fontWeight: FontWeight.w600, 836 - ), 837 - ), 838 - ], 809 + Text( 810 + (profile.displayName ?? profile.handle).toUpperCase(), 811 + style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.5), 839 812 ), 840 813 const SizedBox(height: 4), 841 814 ··· 907 880 ); 908 881 } 909 882 910 - Widget _buildMetaChip( 911 - BuildContext context, 912 - IconData icon, 913 - String label, { 914 - IconData? trailingIcon, 915 - VoidCallback? onTap, 916 - }) { 883 + Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { 917 884 final chip = Container( 918 885 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 919 886 decoration: BoxDecoration( ··· 931 898 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 932 899 ), 933 900 ), 934 - if (trailingIcon != null) ...[ 935 - const SizedBox(width: 6), 936 - Icon(trailingIcon, size: 14, color: context.colorScheme.onSurfaceVariant), 937 - ], 938 901 ], 939 902 ), 940 903 );
-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 - 328 313 testWidgets('opens profile connections route with requested initial tab', (tester) async { 329 314 await tester.binding.setSurfaceSize(const Size(430, 932)); 330 315 addTearDown(() => tester.binding.setSurfaceSize(null));
+1 -224
test/features/profile/data/profile_repository_test.dart
··· 1 1 import 'dart:convert'; 2 - import 'dart:typed_data'; 3 2 4 3 import 'package:atproto_core/atproto_core.dart' as atp_core; 5 4 import 'package:bluesky/app_bsky_actor_defs.dart'; ··· 218 217 expect(profiles.length, 26); 219 218 expect(profiles.map((p) => p.did), orderedEquals(actors)); 220 219 }); 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 - }); 334 220 }); 335 221 } 336 222 ··· 348 234 } 349 235 350 236 class _FakeBlueskyClient { 351 - _FakeBlueskyClient({required this.actor, _FakeGraphService? graph, _FakeAtprotoService? atproto}) 352 - : graph = graph ?? _FakeGraphService(), 353 - atproto = atproto ?? _FakeAtprotoService(repo: _FakeRepoService(record: const {})); 237 + _FakeBlueskyClient({required this.actor, _FakeGraphService? graph}) : graph = graph ?? _FakeGraphService(); 354 238 355 239 final _FakeActorService actor; 356 240 final _FakeGraphService graph; 357 - final _FakeAtprotoService atproto; 358 241 } 359 242 360 243 class _FakeActorService { ··· 394 277 const _FakeProfilesData(this.profiles); 395 278 396 279 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; 503 280 } 504 281 505 282 class _FakeGraphService {
-191
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 - 137 - router.dispose(); 138 - }); 139 - 140 - testWidgets('saves normalized profile edits and returns to own profile', (tester) async { 141 - final router = buildRouter(); 142 - 143 - await tester.pumpWidget(buildSubject(router)); 144 - await tester.pumpAndSettle(); 145 - 146 - await tester.enterText(find.byKey(const ValueKey('profile_edit_display_name_field')), 'Serenity'); 147 - await tester.enterText(find.byKey(const ValueKey('profile_edit_description_field')), 'New bio'); 148 - await tester.enterText(find.byKey(const ValueKey('profile_edit_pronouns_field')), 'they/them'); 149 - await tester.enterText(find.byKey(const ValueKey('profile_edit_website_field')), 'serenity.example'); 150 - await tester.tap(find.byKey(const ValueKey('profile_edit_save_button'))); 151 - await tester.pumpAndSettle(); 152 - 153 - final captured = 154 - verify( 155 - () => profileRepository.updateProfile( 156 - did: 'did:plc:me', 157 - draft: captureAny(named: 'draft'), 158 - ), 159 - ).captured.single 160 - as ProfileEditDraft; 161 - expect(captured.displayName, 'Serenity'); 162 - expect(captured.description, 'New bio'); 163 - expect(captured.pronouns, 'they/them'); 164 - expect(captured.website, 'https://serenity.example'); 165 - verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); 166 - expect(find.text('profile-me'), findsOneWidget); 167 - 168 - router.dispose(); 169 - }); 170 - 171 - testWidgets('validates website input before saving', (tester) async { 172 - final router = buildRouter(); 173 - 174 - await tester.pumpWidget(buildSubject(router)); 175 - await tester.pumpAndSettle(); 176 - 177 - await tester.enterText(find.byKey(const ValueKey('profile_edit_website_field')), 'ftp://example.com'); 178 - await tester.tap(find.byKey(const ValueKey('profile_edit_save_button'))); 179 - await tester.pump(); 180 - 181 - expect(find.text('Enter a valid website'), findsOneWidget); 182 - verifyNever( 183 - () => profileRepository.updateProfile( 184 - did: any(named: 'did'), 185 - draft: any(named: 'draft'), 186 - ), 187 - ); 188 - 189 - router.dispose(); 190 - }); 191 - }
+1 -44
test/features/profile/presentation/profile_screen_test.dart
··· 169 169 findsOneWidget, 170 170 ); 171 171 expect(find.text('she/her'), findsOneWidget); 172 - final nameTop = tester.getTopLeft(find.text('RIVER TAM')).dy; 173 - final pronounsTop = tester.getTopLeft(find.text('she/her')).dy; 174 - expect((nameTop - pronounsTop).abs(), lessThan(12)); 175 172 expect(find.text('river.example'), findsOneWidget); 176 - expect(find.byIcon(Icons.open_in_new), findsOneWidget); 177 173 expect(find.text('Joined March 2024'), findsOneWidget); 178 174 }); 179 175 ··· 184 180 expect(find.text('River Tam'), findsOneWidget); 185 181 }); 186 182 187 - testWidgets('shows own profile header edit action and shortcut buttons', (tester) async { 183 + testWidgets('shows separate Bookmarks and Liked buttons on own profile', (tester) async { 188 184 useLargeScreen(tester); 189 185 await tester.pumpWidget(buildSubject()); 190 186 191 - expect(find.byKey(const Key('profile_edit_header_button')), findsOneWidget); 192 - expect(find.text('Edit Profile'), findsNothing); 193 187 expect(find.text('Bookmarks'), findsOneWidget); 194 188 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(); 230 189 }); 231 190 232 191 testWidgets('tapping following stat opens connections screen on following tab', (tester) async { ··· 297 256 298 257 await tester.pumpWidget(widget); 299 258 300 - expect(find.byKey(const Key('profile_edit_header_button')), findsNothing); 301 - expect(find.text('Edit Profile'), findsNothing); 302 259 expect(find.text('Bookmarks'), findsNothing); 303 260 expect(find.text('Liked'), findsNothing); 304 261 });