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: profile editing functionality with field level validation and image uploads

+1043 -4
+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 {
+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 + }
+24 -2
lib/features/profile/presentation/profile_screen.dart
··· 792 792 if (profile.pronouns?.isNotEmpty ?? false) 793 793 _buildMetaChip(context, Icons.record_voice_over_outlined, profile.pronouns!), 794 794 if (profile.website?.isNotEmpty ?? false) 795 - _buildMetaChip(context, Icons.link_outlined, profile.website!, onTap: () => _launchWebsite(profile.website!)), 795 + _buildMetaChip( 796 + context, 797 + Icons.link_outlined, 798 + profile.website!, 799 + trailingIcon: Icons.open_in_new, 800 + onTap: () => _launchWebsite(profile.website!), 801 + ), 796 802 if (profile.createdAt != null) 797 803 _buildMetaChip( 798 804 context, ··· 862 868 runSpacing: 8, 863 869 children: [ 864 870 OutlinedButton.icon( 871 + key: const ValueKey('profile_edit_button'), 872 + onPressed: () => context.push('/profile/me/edit'), 873 + icon: const Icon(Icons.edit_outlined), 874 + label: const Text('Edit Profile'), 875 + ), 876 + OutlinedButton.icon( 865 877 onPressed: () => context.push('/bookmarks'), 866 878 icon: const Icon(Icons.bookmark_outline), 867 879 label: const Text('Bookmarks'), ··· 880 892 ); 881 893 } 882 894 883 - Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { 895 + Widget _buildMetaChip( 896 + BuildContext context, 897 + IconData icon, 898 + String label, { 899 + IconData? trailingIcon, 900 + VoidCallback? onTap, 901 + }) { 884 902 final chip = Container( 885 903 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 886 904 decoration: BoxDecoration( ··· 898 916 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 899 917 ), 900 918 ), 919 + if (trailingIcon != null) ...[ 920 + const SizedBox(width: 6), 921 + Icon(trailingIcon, size: 14, color: context.colorScheme.onSurfaceVariant), 922 + ], 901 923 ], 902 924 ), 903 925 );
+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 {
+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 + }
+39 -1
test/features/profile/presentation/profile_screen_test.dart
··· 170 170 ); 171 171 expect(find.text('she/her'), findsOneWidget); 172 172 expect(find.text('river.example'), findsOneWidget); 173 + expect(find.byIcon(Icons.open_in_new), findsOneWidget); 173 174 expect(find.text('Joined March 2024'), findsOneWidget); 174 175 }); 175 176 ··· 180 181 expect(find.text('River Tam'), findsOneWidget); 181 182 }); 182 183 183 - testWidgets('shows separate Bookmarks and Liked buttons on own profile', (tester) async { 184 + testWidgets('shows own profile shortcut buttons', (tester) async { 184 185 useLargeScreen(tester); 185 186 await tester.pumpWidget(buildSubject()); 186 187 188 + expect(find.text('Edit Profile'), findsOneWidget); 187 189 expect(find.text('Bookmarks'), findsOneWidget); 188 190 expect(find.text('Liked'), findsOneWidget); 191 + }); 192 + 193 + testWidgets('edit profile shortcut opens the profile edit route', (tester) async { 194 + useLargeScreen(tester); 195 + final router = GoRouter( 196 + routes: [ 197 + GoRoute( 198 + path: '/', 199 + builder: (context, state) => MultiBlocProvider( 200 + providers: [ 201 + BlocProvider<AuthBloc>.value(value: authBloc), 202 + BlocProvider<ProfileBloc>.value(value: profileBloc), 203 + BlocProvider<FeedBloc>.value(value: feedBloc), 204 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 205 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 206 + ], 207 + child: const ProfileScreen(), 208 + ), 209 + ), 210 + GoRoute( 211 + path: '/profile/me/edit', 212 + builder: (context, state) => const Scaffold(body: Text('edit-profile')), 213 + ), 214 + ], 215 + ); 216 + 217 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 218 + await tester.pumpAndSettle(); 219 + 220 + await tester.tap(find.byKey(const ValueKey('profile_edit_button'))); 221 + await tester.pumpAndSettle(); 222 + 223 + expect(find.text('edit-profile'), findsOneWidget); 224 + 225 + router.dispose(); 189 226 }); 190 227 191 228 testWidgets('tapping following stat opens connections screen on following tab', (tester) async { ··· 256 293 257 294 await tester.pumpWidget(widget); 258 295 296 + expect(find.text('Edit Profile'), findsNothing); 259 297 expect(find.text('Bookmarks'), findsNothing); 260 298 expect(find.text('Liked'), findsNothing); 261 299 });