diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65c8d4f..5d75d26 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,12 @@ + + + ?android:colorBackground + + + diff --git a/ios/Podfile b/ios/Podfile index 620e46e..765fbf8 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +# Minimum iOS version for image_cropper (TOCropViewController) and other dependencies +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 15cc065..6cc4a53 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,6 +45,10 @@ UIApplicationSupportsIndirectInputEvents + NSPhotoLibraryUsageDescription + Coves needs access to your photo library to select images for your profile and posts. + NSCameraUsageDescription + Coves needs access to your camera to take photos for your profile and posts. CFBundleURLTypes diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 5c2eeed..0417fa1 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -521,6 +521,53 @@ class UserProfileProvider with ChangeNotifier { await loadComments(refresh: true); } + /// Update the current user's profile + /// + /// Only non-null parameters will be sent to the API. + /// On success, force refreshes the profile from server to get updated URLs. + /// + /// Parameters: + /// - [displayName]: New display name (optional) + /// - [bio]: New bio text (optional) + /// - [avatarBytes]: Avatar image bytes (optional) + /// - [avatarMimeType]: Avatar MIME type (required if avatarBytes provided) + /// - [bannerBytes]: Banner image bytes (optional) + /// - [bannerMimeType]: Banner MIME type (required if bannerBytes provided) + /// + /// Throws [ApiException] on failure. + Future updateProfile({ + String? displayName, + String? bio, + Uint8List? avatarBytes, + String? avatarMimeType, + Uint8List? bannerBytes, + String? bannerMimeType, + }) async { + if (!isOwnProfile || _profile == null) { + throw ApiException('Can only update own profile'); + } + + if (kDebugMode) { + debugPrint('📝 Updating profile for: $_currentProfileDid'); + } + + await _apiService.updateProfile( + displayName: displayName, + bio: bio, + avatarBytes: avatarBytes, + avatarMimeType: avatarMimeType, + bannerBytes: bannerBytes, + bannerMimeType: bannerMimeType, + ); + + // Force refresh profile from server to get updated URLs + await loadProfile(_currentProfileDid!, forceRefresh: true); + + if (kDebugMode) { + debugPrint('✅ Profile updated and refreshed'); + } + } + @override void dispose() { _authProvider.removeListener(_onAuthChanged); diff --git a/lib/screens/home/communities_admin_panel.dart b/lib/screens/home/communities_admin_panel.dart index 9426d9c..0bda94e 100644 --- a/lib/screens/home/communities_admin_panel.dart +++ b/lib/screens/home/communities_admin_panel.dart @@ -11,6 +11,7 @@ import '../../models/picked_image.dart'; import '../../providers/auth_provider.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; +import '../../utils/image_crop_utils.dart'; import '../../utils/image_picker_utils.dart'; import '../../widgets/image_source_picker.dart'; @@ -989,7 +990,10 @@ class _CommunitiesAdminPanelState extends State { if (source == null) return; try { - final picked = await ImagePickerUtils.pickImage(source); + // Pick image and open native cropper + final picked = await ImageCropUtils.pickAndCropImage( + source: source, + ); if (picked != null && mounted) { setState(() { _selectedImage = picked; @@ -1016,7 +1020,7 @@ class _CommunitiesAdminPanelState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to pick image: ${e.toString()}'), + content: Text('Failed to process image: ${e.toString()}'), backgroundColor: Colors.red[700], behavior: SnackBarBehavior.floating, ), diff --git a/lib/screens/home/edit_profile_screen.dart b/lib/screens/home/edit_profile_screen.dart new file mode 100644 index 0000000..11ee946 --- /dev/null +++ b/lib/screens/home/edit_profile_screen.dart @@ -0,0 +1,557 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../constants/app_colors.dart'; +import '../../models/picked_image.dart'; +import '../../models/user_profile.dart'; +import '../../providers/user_profile_provider.dart'; +import '../../services/api_exceptions.dart'; +import '../../utils/image_crop_utils.dart'; +import '../../utils/image_picker_utils.dart'; +import '../../widgets/image_source_picker.dart'; + +/// Content limits matching backend lexicon +const int kDisplayNameMaxLength = 64; +const int kBioMaxLength = 256; + +/// Edit Profile Screen +/// +/// Full-screen interface for editing user profile (avatar, banner, display +/// name, and bio). +/// +/// Features: +/// - Tappable avatar and banner to change images +/// - Current vs. new image preview when changed +/// - Display name and bio text fields with character limits +/// - Form validation and error handling +/// - Loading states during save +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({required this.profile, super.key}); + + /// Current user profile to edit + final UserProfile profile; + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + // Text controllers + late final TextEditingController _displayNameController; + late final TextEditingController _bioController; + + // Image state + PickedImage? _selectedAvatar; + PickedImage? _selectedBanner; + + // Form state + bool _isSaving = false; + + @override + void initState() { + super.initState(); + // Initialize controllers with current profile values + _displayNameController = TextEditingController( + text: widget.profile.displayName ?? '', + ); + _bioController = TextEditingController( + text: widget.profile.bio ?? '', + ); + } + + @override + void dispose() { + _displayNameController.dispose(); + _bioController.dispose(); + super.dispose(); + } + + /// Check if any changes have been made + bool get _hasChanges { + final displayNameChanged = + _displayNameController.text != (widget.profile.displayName ?? ''); + final bioChanged = _bioController.text != (widget.profile.bio ?? ''); + return displayNameChanged || + bioChanged || + _selectedAvatar != null || + _selectedBanner != null; + } + + Future _pickAvatar() async { + final source = await ImageSourcePicker.show(context); + if (source == null) return; + + try { + final picked = await ImageCropUtils.pickAndCropImage( + source: source, + constraints: ImageConstraints.avatar, + cropConfig: CropConfig.avatar, + ); + if (picked != null && mounted) { + setState(() { + _selectedAvatar = picked; + }); + } + } on ImageValidationException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.message), + backgroundColor: Colors.red.shade700, + ), + ); + } + } on Exception catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to process image: $e'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + } + + Future _pickBanner() async { + final source = await ImageSourcePicker.show(context); + if (source == null) return; + + try { + final picked = await ImageCropUtils.pickAndCropImage( + source: source, + constraints: ImageConstraints.banner, + cropConfig: CropConfig.banner, + ); + if (picked != null && mounted) { + setState(() { + _selectedBanner = picked; + }); + } + } on ImageValidationException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.message), + backgroundColor: Colors.red.shade700, + ), + ); + } + } on Exception catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to process image: $e'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + } + + Future _saveProfile() async { + if (_isSaving || !_hasChanges) return; + + setState(() { + _isSaving = true; + }); + + try { + final profileProvider = context.read(); + + // Determine what to send (only changed fields) + final displayName = _displayNameController.text.trim(); + final bio = _bioController.text.trim(); + + // Only send text fields if they changed + final sendDisplayName = + displayName != (widget.profile.displayName ?? ''); + final sendBio = bio != (widget.profile.bio ?? ''); + + await profileProvider.updateProfile( + displayName: sendDisplayName ? displayName : null, + bio: sendBio ? bio : null, + avatarBytes: _selectedAvatar?.bytes, + avatarMimeType: _selectedAvatar?.mimeType, + bannerBytes: _selectedBanner?.bytes, + bannerMimeType: _selectedBanner?.mimeType, + ); + + // Check mounted after async gap + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile updated successfully'), + backgroundColor: AppColors.primary, + ), + ); + + Navigator.pop(context); + } on ApiException catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.message), + backgroundColor: Colors.red.shade700, + ), + ); + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update profile: $e'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, + title: const Text('Edit Profile'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + actions: [ + TextButton( + onPressed: (_hasChanges && !_isSaving) ? _saveProfile : null, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ) + : Text( + 'Save', + style: TextStyle( + color: _hasChanges + ? AppColors.primary + : AppColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Banner section + _buildBannerSection(), + // Avatar section + _buildAvatarSection(), + const SizedBox(height: 24), + // Text fields + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTextField( + controller: _displayNameController, + label: 'Display Name', + hint: 'How your name appears', + maxLength: kDisplayNameMaxLength, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _bioController, + label: 'Bio', + hint: 'Tell people about yourself', + maxLength: kBioMaxLength, + maxLines: 4, + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildBannerSection() { + return GestureDetector( + onTap: _pickBanner, + child: Stack( + children: [ + // Banner image + SizedBox( + height: 150, + width: double.infinity, + child: _selectedBanner != null + ? Image.file( + _selectedBanner!.file, + fit: BoxFit.cover, + ) + : (widget.profile.banner != null && + widget.profile.banner!.isNotEmpty) + ? CachedNetworkImage( + imageUrl: widget.profile.banner!, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorWidget: (context, url, error) => + _buildDefaultBanner(), + ) + : _buildDefaultBanner(), + ), + // Overlay with edit indicator + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.3), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.camera_alt, + color: Colors.white, + size: 32, + ), + SizedBox(height: 4), + Text( + 'Change Banner', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + // "New" indicator if banner changed + if (_selectedBanner != null) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'NEW', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildDefaultBanner() { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary.withValues(alpha: 0.6), + AppColors.primary.withValues(alpha: 0.3), + ], + ), + ), + ); + } + + Widget _buildAvatarSection() { + const avatarSize = 100.0; + + return Transform.translate( + offset: const Offset(0, -40), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + GestureDetector( + onTap: _pickAvatar, + child: Stack( + children: [ + // Avatar container + Container( + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.background, + width: 4, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipOval( + child: Stack( + children: [ + // Avatar image + SizedBox( + width: avatarSize - 8, + height: avatarSize - 8, + child: _selectedAvatar != null + ? Image.file( + _selectedAvatar!.file, + fit: BoxFit.cover, + ) + : (widget.profile.avatar != null) + ? CachedNetworkImage( + imageUrl: widget.profile.avatar!, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorWidget: (context, url, error) => + _buildFallbackAvatar( + avatarSize - 8, + ), + ) + : _buildFallbackAvatar(avatarSize - 8), + ), + // Edit overlay + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.4), + child: const Center( + child: Icon( + Icons.camera_alt, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ], + ), + ), + ), + // "New" indicator if avatar changed + if (_selectedAvatar != null) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'NEW', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildFallbackAvatar(double size) { + return Container( + width: size, + height: size, + color: AppColors.primary, + child: Icon( + Icons.person, + size: size * 0.5, + color: Colors.white, + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required String hint, + required int maxLength, + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + maxLength: maxLength, + maxLines: maxLines, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + color: AppColors.textSecondary.withValues(alpha: 0.5), + ), + filled: true, + fillColor: AppColors.backgroundSecondary, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + counterStyle: const TextStyle(color: AppColors.textSecondary), + ), + onChanged: (_) => setState(() {}), // Trigger rebuild for hasChanges + ), + ], + ); + } +} diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index b8888cd..f7f4ed9 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import '../../constants/app_colors.dart'; import '../../models/comment.dart'; +import '../../models/user_profile.dart'; import '../../providers/auth_provider.dart'; import '../../providers/user_profile_provider.dart'; import '../../widgets/comment_card.dart'; @@ -14,6 +15,7 @@ import '../../widgets/loading_error_states.dart'; import '../../widgets/post_card.dart'; import '../../widgets/primary_button.dart'; import '../../widgets/profile_header.dart'; +import 'edit_profile_screen.dart'; /// Profile screen displaying user profile with header and posts /// @@ -158,6 +160,15 @@ class _ProfileScreenState extends State { context.go('/login'); } + void _navigateToEditProfile(BuildContext context, UserProfile profile) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EditProfileScreen(profile: profile), + ), + ); + } + @override Widget build(BuildContext context) { final authProvider = context.watch(); @@ -234,6 +245,15 @@ class _ProfileScreenState extends State { automaticallyImplyLeading: widget.actor != null, actions: profileProvider.isOwnProfile ? [ + if (profileProvider.profile != null) + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => _navigateToEditProfile( + context, + profileProvider.profile!, + ), + tooltip: 'Edit Profile', + ), IconButton( icon: const Icon(Icons.share_outlined), onPressed: _handleShare, @@ -269,7 +289,6 @@ class _ProfileScreenState extends State { bottom: 0, child: ProfileHeader( profile: profileProvider.profile, - isOwnProfile: profileProvider.isOwnProfile, ), ), // Frosted glass overlay when collapsed diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index fa24113..73e8c0b 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -1004,8 +1004,155 @@ class CovesApiService { } } + /// Update the authenticated user's profile + /// + /// All parameters are optional - only non-null values will be sent to + /// the API. This allows updating individual fields without affecting others. + /// + /// Parameters: + /// - [displayName]: New display name (optional, max 64 chars) + /// - [bio]: New bio text (optional, max 256 chars) + /// - [avatarBytes]: Avatar image bytes (optional, max 1 MB) + /// - [avatarMimeType]: Avatar MIME type (required if avatarBytes provided) + /// - [bannerBytes]: Banner image bytes (optional, max 2 MB) + /// - [bannerMimeType]: Banner MIME type (required if bannerBytes provided) + /// + /// Returns [UpdateProfileResponse] with URI and CID of updated profile. + /// + /// Throws: + /// - [ApiException] if validation fails (size, MIME type, missing params) + /// - [AuthenticationException] if not authenticated + /// - [ApiException] for other API errors + Future updateProfile({ + String? displayName, + String? bio, + Uint8List? avatarBytes, + String? avatarMimeType, + Uint8List? bannerBytes, + String? bannerMimeType, + }) async { + // Validate avatar if provided + if (avatarBytes != null) { + if (avatarMimeType == null) { + throw ApiException('avatarMimeType required when avatarBytes provided'); + } + const maxAvatarBytes = 1024 * 1024; // 1 MB + if (avatarBytes.length > maxAvatarBytes) { + throw ApiException( + 'Avatar size exceeds maximum of 1 MB ' + '(${(avatarBytes.length / 1024 / 1024).toStringAsFixed(2)} MB)', + ); + } + _validateImageMimeType(avatarMimeType); + } + + // Validate banner if provided + if (bannerBytes != null) { + if (bannerMimeType == null) { + throw ApiException('bannerMimeType required when bannerBytes provided'); + } + const maxBannerBytes = 2 * 1024 * 1024; // 2 MB + if (bannerBytes.length > maxBannerBytes) { + throw ApiException( + 'Banner size exceeds maximum of 2 MB ' + '(${(bannerBytes.length / 1024 / 1024).toStringAsFixed(2)} MB)', + ); + } + _validateImageMimeType(bannerMimeType); + } + + try { + if (kDebugMode) { + debugPrint( + '📡 Updating profile: ' + 'displayName=${displayName != null}, ' + 'bio=${bio != null}, ' + 'avatar=${avatarBytes != null ? "${avatarBytes.length} bytes" : "null"}, ' + 'banner=${bannerBytes != null ? "${bannerBytes.length} bytes" : "null"}', + ); + } + + // Build request body with only non-null fields + final requestBody = {}; + + if (displayName != null) { + requestBody['displayName'] = displayName; + } + + if (bio != null) { + requestBody['bio'] = bio; + } + + if (avatarBytes != null) { + requestBody['avatarBlob'] = base64Encode(avatarBytes); + requestBody['avatarMimeType'] = avatarMimeType; + } + + if (bannerBytes != null) { + requestBody['bannerBlob'] = base64Encode(bannerBytes); + requestBody['bannerMimeType'] = bannerMimeType; + } + + final response = await _dio.post( + '/xrpc/social.coves.actor.updateProfile', + data: requestBody, + ); + + if (kDebugMode) { + debugPrint('✅ Profile updated successfully'); + } + + return UpdateProfileResponse.fromJson( + response.data as Map, + ); + } on DioException catch (e) { + _handleDioException(e, 'update profile'); + } catch (e) { + if (e is ApiException) { + rethrow; + } + if (kDebugMode) { + debugPrint('❌ Error updating profile: $e'); + } + throw ApiException('Failed to update profile', originalError: e); + } + } + + /// Validate image MIME type for profile images + void _validateImageMimeType(String mimeType) { + const supportedMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; + if (!supportedMimeTypes.contains(mimeType)) { + throw ApiException( + 'Unsupported image type: $mimeType. ' + 'Supported types: ${supportedMimeTypes.join(', ')}', + ); + } + } + /// Dispose resources void dispose() { _dio.close(); } } + +/// Response from POST /xrpc/social.coves.actor.updateProfile +class UpdateProfileResponse { + const UpdateProfileResponse({required this.uri, required this.cid}); + + factory UpdateProfileResponse.fromJson(Map json) { + final uri = json['uri']; + final cid = json['cid']; + + if (uri is! String || uri.isEmpty) { + throw const FormatException('UpdateProfileResponse: missing or invalid uri'); + } + if (cid is! String || cid.isEmpty) { + throw const FormatException('UpdateProfileResponse: missing or invalid cid'); + } + + return UpdateProfileResponse(uri: uri, cid: cid); + } + + final String uri; + final String cid; +} diff --git a/lib/utils/image_crop_utils.dart b/lib/utils/image_crop_utils.dart new file mode 100644 index 0000000..69f6020 --- /dev/null +++ b/lib/utils/image_crop_utils.dart @@ -0,0 +1,220 @@ +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../constants/app_colors.dart'; +import '../models/picked_image.dart'; +import 'image_picker_utils.dart'; + +/// Configuration for image cropping behavior. +class CropConfig { + const CropConfig({ + this.title = 'Crop Image', + this.cropStyle = CropStyle.circle, + this.aspectRatio = const CropAspectRatio(ratioX: 1, ratioY: 1), + this.lockAspectRatio = true, + this.compressQuality = 90, + }) : assert( + compressQuality >= 0 && compressQuality <= 100, + 'compressQuality must be between 0 and 100', + ); + + /// Title shown in the cropper UI + final String title; + + /// Crop style (circle for avatars, rectangle for banners) + final CropStyle cropStyle; + + /// Aspect ratio for the crop area + final CropAspectRatio aspectRatio; + + /// Whether to lock the aspect ratio + final bool lockAspectRatio; + + /// JPEG compression quality (0-100) + final int compressQuality; + + /// Creates a copy with the given fields replaced. + CropConfig copyWith({ + String? title, + CropStyle? cropStyle, + CropAspectRatio? aspectRatio, + bool? lockAspectRatio, + int? compressQuality, + }) { + return CropConfig( + title: title ?? this.title, + cropStyle: cropStyle ?? this.cropStyle, + aspectRatio: aspectRatio ?? this.aspectRatio, + lockAspectRatio: lockAspectRatio ?? this.lockAspectRatio, + compressQuality: compressQuality ?? this.compressQuality, + ); + } + + /// Default configuration for circular avatar cropping + static const avatar = CropConfig(); + + /// Configuration for rectangular banner images + static const banner = CropConfig( + title: 'Crop Banner', + cropStyle: CropStyle.rectangle, + aspectRatio: CropAspectRatio(ratioX: 3, ratioY: 1), + ); +} + +/// Utility for cropping images using native platform croppers. +/// +/// Uses [image_cropper] which provides native UI on both iOS (TOCropViewController) +/// and Android (uCrop) for a polished, platform-consistent experience. +abstract final class ImageCropUtils { + /// Crops an image file using the native platform cropper. + /// + /// Returns a [CroppedFile] containing the cropped image, or null if cancelled. + /// + /// [sourcePath] - Path to the source image file + /// [config] - Optional crop configuration (defaults to avatar) + /// + /// Throws [PlatformException] if the native cropper fails. + static Future cropImage({ + required String sourcePath, + CropConfig config = CropConfig.avatar, + }) async { + try { + return await ImageCropper().cropImage( + sourcePath: sourcePath, + aspectRatio: config.aspectRatio, + compressQuality: config.compressQuality, + uiSettings: [ + AndroidUiSettings( + toolbarTitle: config.title, + toolbarColor: AppColors.background, + toolbarWidgetColor: Colors.white, + statusBarLight: false, + navBarLight: false, + backgroundColor: AppColors.background, + activeControlsWidgetColor: AppColors.primary, + cropFrameColor: AppColors.primary, + cropGridColor: Colors.white.withValues(alpha: 0.3), + dimmedLayerColor: AppColors.background.withValues(alpha: 0.7), + cropStyle: config.cropStyle, + lockAspectRatio: config.lockAspectRatio, + hideBottomControls: true, + showCropGrid: true, + cropGridRowCount: 2, + cropGridColumnCount: 2, + ), + IOSUiSettings( + title: config.title, + doneButtonTitle: 'Done', + cancelButtonTitle: 'Cancel', + aspectRatioLockEnabled: config.lockAspectRatio, + resetAspectRatioEnabled: !config.lockAspectRatio, + aspectRatioPickerButtonHidden: config.lockAspectRatio, + rotateButtonsHidden: false, + rotateClockwiseButtonHidden: true, + ), + ], + ); + } on PlatformException catch (e, stackTrace) { + developer.log( + 'Native image cropper failed', + name: 'ImageCropUtils', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// Picks an image and opens the native cropper. + /// + /// Combines [ImagePickerUtils.pickImage] with [cropImage] for a + /// complete pick-and-crop flow using native platform UI. + /// + /// Returns [PickedImage] with cropped image data, or null if cancelled + /// at any step (image picking or cropping). + /// + /// Throws [ImageValidationException] if the picked image fails validation. + /// + /// Example: + /// ```dart + /// final source = await ImageSourcePicker.show(context); + /// if (source != null) { + /// final result = await ImageCropUtils.pickAndCropImage( + /// source: source, + /// ); + /// if (result != null) { + /// // Upload result.bytes + /// } + /// } + /// ``` + static Future pickAndCropImage({ + required ImageSource source, + ImageConstraints constraints = ImageConstraints.avatar, + CropConfig cropConfig = CropConfig.avatar, + }) async { + final picked = await ImagePickerUtils.pickImage( + source, + constraints: constraints, + ); + + if (picked == null) { + developer.log( + 'Image picking cancelled or failed', + name: 'ImageCropUtils', + ); + return null; + } + + final croppedFile = await cropImage( + sourcePath: picked.file.path, + config: cropConfig, + ); + + if (croppedFile == null) { + developer.log( + 'Image cropping cancelled by user', + name: 'ImageCropUtils', + ); + return null; + } + + final croppedFileObj = File(croppedFile.path); + + final Uint8List croppedBytes; + try { + croppedBytes = await croppedFileObj.readAsBytes(); + } on FileSystemException catch (e, stackTrace) { + developer.log( + 'Failed to read cropped file', + name: 'ImageCropUtils', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + + // Validate cropped file size against constraints + if (croppedBytes.length > constraints.maxSizeBytes) { + throw ImageValidationException( + 'Cropped image is too large ' + '(${(croppedBytes.length / 1024 / 1024).toStringAsFixed(1)} MB). ' + 'Maximum size is ${(constraints.maxSizeBytes / 1024 / 1024).toStringAsFixed(1)} MB.', + ); + } + + final mimeType = ImagePickerUtils.inferMimeTypeFromExtension( + croppedFile.path, + ); + + return PickedImage( + file: croppedFileObj, + bytes: croppedBytes, + mimeType: mimeType, + ); + } +} diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart index cb2c9ea..600f6aa 100644 --- a/lib/widgets/profile_header.dart +++ b/lib/widgets/profile_header.dart @@ -15,18 +15,10 @@ import '../utils/date_time_utils.dart'; class ProfileHeader extends StatelessWidget { const ProfileHeader({ required this.profile, - required this.isOwnProfile, - this.onEditPressed, - this.onMenuPressed, - this.onSharePressed, super.key, }); final UserProfile? profile; - final bool isOwnProfile; - final VoidCallback? onEditPressed; - final VoidCallback? onMenuPressed; - final VoidCallback? onSharePressed; static const double bannerHeight = 150; @@ -225,12 +217,15 @@ class ProfileHeader extends StatelessWidget { color: AppColors.textSecondary, ), const SizedBox(width: 4), - Text( - profile!.did, - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - fontFamily: 'monospace', + Expanded( + child: Text( + profile!.did, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -239,13 +234,6 @@ class ProfileHeader extends StatelessWidget { ], ), ), - // Edit button for own profile - if (isOwnProfile && onEditPressed != null) - _ActionButton( - icon: Icons.edit_outlined, - onPressed: onEditPressed!, - tooltip: 'Edit Profile', - ), ], ), ); @@ -302,38 +290,6 @@ class ProfileHeader extends StatelessWidget { } } -/// Small action button for profile actions -class _ActionButton extends StatelessWidget { - const _ActionButton({ - required this.icon, - required this.onPressed, - this.tooltip, - }); - - final IconData icon; - final VoidCallback onPressed; - final String? tooltip; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip ?? '', - child: Material( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon(icon, size: 20, color: AppColors.textSecondary), - ), - ), - ), - ); - } -} - /// Stats item showing label and value class _StatItem extends StatelessWidget { const _StatItem({ diff --git a/pubspec.lock b/pubspec.lock index 8fbe022..e520740 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -504,6 +504,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_cropper: + dependency: "direct main" + description: + name: image_cropper + sha256: "266760ed426d7121f0ada02c672bfe5c1b5c714e908328716aee756f045709dc" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + image_cropper_for_web: + dependency: transitive + description: + name: image_cropper_for_web + sha256: fd81ebe36f636576094377aab32673c4e5d1609b32dec16fad98d2b71f1250a9 + url: "https://pub.dev" + source: hosted + version: "6.1.0" + image_cropper_platform_interface: + dependency: transitive + description: + name: image_cropper_platform_interface + sha256: "2d8db8f4b638e448fa89a1e77cd8f053b4547472bd3ae073169e86626d03afef" + url: "https://pub.dev" + source: hosted + version: "7.2.0" image_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6210f43..6112f54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: share_plus: ^10.1.4 sentry_flutter: ^8.10.1 image_picker: ^1.1.2 + image_cropper: ^8.0.2 dev_dependencies: flutter_test: