diff --git a/.claude/settings.json b/.claude/settings.json index a985f0c..3af5985 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "enabledPlugins": { - "pr-review-toolkit@claude-plugins-official": true + "pr-review-toolkit@claude-plugins-official": true, + "commit-commands@claude-plugins-official": true } } diff --git a/lib/models/picked_image.dart b/lib/models/picked_image.dart new file mode 100644 index 0000000..3a92eda --- /dev/null +++ b/lib/models/picked_image.dart @@ -0,0 +1,29 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// Represents a picked and validated image ready for upload. +/// +/// Contains the file, raw bytes, and detected MIME type +/// for convenient access during upload operations. +class PickedImage { + const PickedImage({ + required this.file, + required this.bytes, + required this.mimeType, + }); + + /// The picked image file + final File file; + + /// Image bytes ready for upload (base64 encoding, etc.) + final Uint8List bytes; + + /// Detected MIME type (jpeg, png, webp, gif, or heic) + final String mimeType; + + /// File path for display or debugging + String get path => file.path; + + /// File size in bytes + int get sizeBytes => bytes.length; +} diff --git a/lib/screens/home/communities_admin_panel.dart b/lib/screens/home/communities_admin_panel.dart index 37b95c8..dc7682a 100644 --- a/lib/screens/home/communities_admin_panel.dart +++ b/lib/screens/home/communities_admin_panel.dart @@ -1,15 +1,17 @@ -import 'dart:io'; +import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; import '../../models/community.dart'; +import '../../models/picked_image.dart'; import '../../providers/auth_provider.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; +import '../../utils/image_picker_utils.dart'; +import '../../widgets/image_source_picker.dart'; /// Admin handles that can create communities const Set kAdminHandles = { @@ -65,8 +67,7 @@ class _CommunitiesAdminPanelState extends State { bool _isLoadingCommunities = false; List _communities = []; CommunityView? _selectedCommunity; - File? _selectedImage; - final ImagePicker _imagePicker = ImagePicker(); + PickedImage? _selectedImage; // Computed state bool get _isFormValid { @@ -696,7 +697,7 @@ class _CommunitiesAdminPanelState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(50), child: Image.file( - _selectedImage!, + _selectedImage!.file, fit: BoxFit.cover, ), ), @@ -962,110 +963,34 @@ class _CommunitiesAdminPanelState extends State { Future _pickAndUploadImage() async { // Show bottom sheet to choose between gallery and camera - final source = await showModalBottomSheet( - context: context, - backgroundColor: AppColors.backgroundSecondary, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: AppColors.border, - borderRadius: BorderRadius.circular(2), - ), - ), - const Text( - 'Select Image Source', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - ListTile( - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.photo_library, - color: AppColors.primary, - ), - ), - title: const Text( - 'Choose from Gallery', - style: TextStyle(color: Colors.white), - ), - subtitle: const Text( - 'Select an existing photo', - style: TextStyle(color: Color(0xFFB6C2D2), fontSize: 12), - ), - onTap: () => Navigator.pop(context, ImageSource.gallery), - ), - ListTile( - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.teal.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.camera_alt, - color: AppColors.teal, - ), - ), - title: const Text( - 'Take a Photo', - style: TextStyle(color: Colors.white), - ), - subtitle: const Text( - 'Use camera to capture', - style: TextStyle(color: Color(0xFFB6C2D2), fontSize: 12), - ), - onTap: () => Navigator.pop(context, ImageSource.camera), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - }, - ); - - if (source == null) { - return; - } + final source = await ImageSourcePicker.show(context); + if (source == null) return; try { - final pickedFile = await _imagePicker.pickImage( - source: source, - maxWidth: 1024, - maxHeight: 1024, - imageQuality: 85, - ); - - if (pickedFile != null && mounted) { + final picked = await ImagePickerUtils.pickImage(source); + if (picked != null && mounted) { setState(() { - _selectedImage = File(pickedFile.path); + _selectedImage = picked; }); } - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('Error picking image: $e'); + } on ImageValidationException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.message), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + ), + ); } + } on Exception catch (e, stackTrace) { + developer.log( + 'Error picking image', + name: 'CommunitiesAdminPanel', + error: e, + stackTrace: stackTrace, + level: 1000, // Error level + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1088,24 +1013,9 @@ class _CommunitiesAdminPanelState extends State { }); try { - // Read the image file as bytes - final imageBytes = await _selectedImage!.readAsBytes(); - - // Determine MIME type from file extension - final extension = _selectedImage!.path.split('.').last.toLowerCase(); - String mimeType; - switch (extension) { - case 'jpg': - case 'jpeg': - mimeType = 'image/jpeg'; - case 'png': - mimeType = 'image/png'; - case 'webp': - mimeType = 'image/webp'; - default: - // Default to JPEG for unknown extensions - mimeType = 'image/jpeg'; - } + // Use bytes and mimeType from PickedImage (already read during picking) + final imageBytes = _selectedImage!.bytes; + final mimeType = _selectedImage!.mimeType; if (kDebugMode) { debugPrint( @@ -1140,10 +1050,14 @@ class _CommunitiesAdminPanelState extends State { // Reload communities list to show updated avatar await _loadCommunities(); } - } on ApiException catch (e) { - if (kDebugMode) { - debugPrint('API error uploading avatar: ${e.message}'); - } + } on ApiException catch (e, stackTrace) { + developer.log( + 'API error uploading avatar', + name: 'CommunitiesAdminPanel', + error: e, + stackTrace: stackTrace, + level: 1000, + ); if (mounted) { setState(() { _isSubmitting = false; @@ -1157,10 +1071,13 @@ class _CommunitiesAdminPanelState extends State { ); } } catch (e, stackTrace) { - if (kDebugMode) { - debugPrint('Unexpected error in _uploadImage: $e'); - debugPrint('Stack trace: $stackTrace'); - } + developer.log( + 'Unexpected error uploading avatar', + name: 'CommunitiesAdminPanel', + error: e, + stackTrace: stackTrace, + level: 1000, + ); if (mounted) { setState(() { _isSubmitting = false; diff --git a/lib/utils/image_picker_utils.dart b/lib/utils/image_picker_utils.dart new file mode 100644 index 0000000..f3106b0 --- /dev/null +++ b/lib/utils/image_picker_utils.dart @@ -0,0 +1,166 @@ +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../models/picked_image.dart'; + +/// Configuration for image picking constraints +class ImageConstraints { + const ImageConstraints({ + this.maxWidth = 1024, + this.maxHeight = 1024, + this.imageQuality = 85, + this.maxSizeBytes = 1024 * 1024, // 1 MB + this.allowedMimeTypes = const {'image/jpeg', 'image/png', 'image/webp'}, + }) : assert(maxWidth > 0, 'maxWidth must be positive'), + assert(maxHeight > 0, 'maxHeight must be positive'), + assert( + imageQuality >= 0 && imageQuality <= 100, + 'imageQuality must be 0-100', + ), + assert(maxSizeBytes > 0, 'maxSizeBytes must be positive'); + + /// Maximum width in pixels (image will be resized if larger) + final double maxWidth; + + /// Maximum height in pixels (image will be resized if larger) + final double maxHeight; + + /// JPEG compression quality (0-100) + final int imageQuality; + + /// Maximum file size in bytes after picking + final int maxSizeBytes; + + /// Set of allowed MIME types + final Set allowedMimeTypes; + + /// Preset for avatar images (profile pics, community avatars) + static const avatar = ImageConstraints(); + + /// Preset for larger images (banners, post images) + static const banner = ImageConstraints( + maxWidth: 2048, + maxSizeBytes: 2 * 1024 * 1024, // 2 MB + ); +} + +/// Thrown when image validation fails +class ImageValidationException implements Exception { + const ImageValidationException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Image picker utility functions +/// +/// Provides reusable image picking, validation, and MIME type detection. +/// All methods are static and stateless for easy testing. +class ImagePickerUtils { + // Private constructor to prevent instantiation + ImagePickerUtils._(); + + static final ImagePicker _picker = ImagePicker(); + + /// Pick an image from the specified source with optional constraints. + /// + /// Returns [PickedImage] with file, bytes, and MIME type, + /// or null if cancelled. + /// Throws [ImageValidationException] if image fails validation. + /// + /// [source] - ImageSource.gallery or ImageSource.camera + /// [constraints] - Optional constraints (defaults to avatar preset) + static Future pickImage( + ImageSource source, { + ImageConstraints constraints = ImageConstraints.avatar, + }) async { + final pickedFile = await _picker.pickImage( + source: source, + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + imageQuality: constraints.imageQuality, + ); + + if (pickedFile == null) { + return null; + } + + final file = File(pickedFile.path); + final bytes = await file.readAsBytes(); + final mimeType = inferMimeTypeFromExtension(pickedFile.path); + + validateImage( + bytes: bytes, + mimeType: mimeType, + constraints: constraints, + ); + + return PickedImage( + file: file, + bytes: bytes, + mimeType: mimeType, + ); + } + + /// Infer MIME type from file path extension. + /// + /// Returns the MIME type string based on file extension. + /// Logs a warning and defaults to 'image/jpeg' for unknown extensions. + static String inferMimeTypeFromExtension(String path) { + final extension = path.split('.').last.toLowerCase(); + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'webp': + return 'image/webp'; + case 'gif': + return 'image/gif'; + case 'heic': + case 'heif': + return 'image/heic'; + default: + developer.log( + 'Unknown image extension ".$extension", defaulting to image/jpeg', + name: 'ImagePickerUtils', + level: 900, // Warning level + ); + return 'image/jpeg'; + } + } + + /// Validate image bytes and MIME type against constraints. + /// + /// Throws [ImageValidationException] if validation fails. + static void validateImage({ + required Uint8List bytes, + required String mimeType, + required ImageConstraints constraints, + }) { + // Check file size + if (bytes.length > constraints.maxSizeBytes) { + final maxSizeMB = constraints.maxSizeBytes / (1024 * 1024); + throw ImageValidationException( + 'Image size exceeds maximum of ${maxSizeMB.toStringAsFixed(0)} MB. ' + 'Please choose a smaller image.', + ); + } + + // Check MIME type + if (!constraints.allowedMimeTypes.contains(mimeType)) { + final allowed = constraints.allowedMimeTypes + .map((t) => t.split('/').last.toUpperCase()) + .join(', '); + throw ImageValidationException( + 'Unsupported image type. Please use $allowed.', + ); + } + } +} diff --git a/lib/widgets/image_source_picker.dart b/lib/widgets/image_source_picker.dart new file mode 100644 index 0000000..3d77330 --- /dev/null +++ b/lib/widgets/image_source_picker.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../constants/app_colors.dart'; + +/// A modal bottom sheet for selecting an image source (gallery or camera). +/// +/// Usage: +/// ```dart +/// final source = await ImageSourcePicker.show(context); +/// if (source != null) { +/// // Pick image using the selected source +/// } +/// ``` +abstract final class ImageSourcePicker { + /// Shows the image source picker modal and returns the selected source. + /// + /// Returns [ImageSource.gallery], [ImageSource.camera], or null if cancelled. + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.backgroundSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => const _ImageSourcePickerSheet(), + ); + } +} + +class _ImageSourcePickerSheet extends StatelessWidget { + const _ImageSourcePickerSheet(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const Text( + 'Select Image Source', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ListTile( + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.photo_library, + color: AppColors.primary, + ), + ), + title: const Text( + 'Choose from Gallery', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Select an existing photo', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12), + ), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.teal.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.camera_alt, + color: AppColors.teal, + ), + ), + title: const Text( + 'Take a Photo', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Use camera to capture', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12), + ), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + + const SizedBox(height: 8), + ], + ), + ), + ); + } +}