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: