Main coves client
1import 'dart:developer' as developer;
2import 'dart:io';
3
4import 'package:flutter/foundation.dart';
5import 'package:image_picker/image_picker.dart';
6
7import '../models/picked_image.dart';
8
9/// Configuration for image picking constraints
10class ImageConstraints {
11 const ImageConstraints({
12 this.maxWidth = 1024,
13 this.maxHeight = 1024,
14 this.imageQuality = 85,
15 this.maxSizeBytes = 1024 * 1024, // 1 MB
16 this.allowedMimeTypes = const {'image/jpeg', 'image/png', 'image/webp'},
17 }) : assert(maxWidth > 0, 'maxWidth must be positive'),
18 assert(maxHeight > 0, 'maxHeight must be positive'),
19 assert(
20 imageQuality >= 0 && imageQuality <= 100,
21 'imageQuality must be 0-100',
22 ),
23 assert(maxSizeBytes > 0, 'maxSizeBytes must be positive');
24
25 /// Maximum width in pixels (image will be resized if larger)
26 final double maxWidth;
27
28 /// Maximum height in pixels (image will be resized if larger)
29 final double maxHeight;
30
31 /// JPEG compression quality (0-100)
32 final int imageQuality;
33
34 /// Maximum file size in bytes after picking
35 final int maxSizeBytes;
36
37 /// Set of allowed MIME types
38 final Set<String> allowedMimeTypes;
39
40 /// Preset for avatar images (profile pics, community avatars)
41 static const avatar = ImageConstraints();
42
43 /// Preset for larger images (banners, post images)
44 static const banner = ImageConstraints(
45 maxWidth: 2048,
46 maxSizeBytes: 2 * 1024 * 1024, // 2 MB
47 );
48}
49
50/// Thrown when image validation fails
51class ImageValidationException implements Exception {
52 const ImageValidationException(this.message);
53
54 final String message;
55
56 @override
57 String toString() => message;
58}
59
60/// Image picker utility functions
61///
62/// Provides reusable image picking, validation, and MIME type detection.
63/// All methods are static and stateless for easy testing.
64class ImagePickerUtils {
65 // Private constructor to prevent instantiation
66 ImagePickerUtils._();
67
68 static final ImagePicker _picker = ImagePicker();
69
70 /// Pick an image from the specified source with optional constraints.
71 ///
72 /// Returns [PickedImage] with file, bytes, and MIME type,
73 /// or null if cancelled.
74 /// Throws [ImageValidationException] if image fails validation.
75 ///
76 /// [source] - ImageSource.gallery or ImageSource.camera
77 /// [constraints] - Optional constraints (defaults to avatar preset)
78 static Future<PickedImage?> pickImage(
79 ImageSource source, {
80 ImageConstraints constraints = ImageConstraints.avatar,
81 }) async {
82 final pickedFile = await _picker.pickImage(
83 source: source,
84 maxWidth: constraints.maxWidth,
85 maxHeight: constraints.maxHeight,
86 imageQuality: constraints.imageQuality,
87 );
88
89 if (pickedFile == null) {
90 return null;
91 }
92
93 final file = File(pickedFile.path);
94 final bytes = await file.readAsBytes();
95 final mimeType = inferMimeTypeFromExtension(pickedFile.path);
96
97 validateImage(
98 bytes: bytes,
99 mimeType: mimeType,
100 constraints: constraints,
101 );
102
103 return PickedImage(
104 file: file,
105 bytes: bytes,
106 mimeType: mimeType,
107 );
108 }
109
110 /// Infer MIME type from file path extension.
111 ///
112 /// Returns the MIME type string based on file extension.
113 /// Logs a warning and defaults to 'image/jpeg' for unknown extensions.
114 static String inferMimeTypeFromExtension(String path) {
115 final extension = path.split('.').last.toLowerCase();
116 switch (extension) {
117 case 'jpg':
118 case 'jpeg':
119 return 'image/jpeg';
120 case 'png':
121 return 'image/png';
122 case 'webp':
123 return 'image/webp';
124 case 'gif':
125 return 'image/gif';
126 case 'heic':
127 case 'heif':
128 return 'image/heic';
129 default:
130 developer.log(
131 'Unknown image extension ".$extension", defaulting to image/jpeg',
132 name: 'ImagePickerUtils',
133 level: 900, // Warning level
134 );
135 return 'image/jpeg';
136 }
137 }
138
139 /// Validate image bytes and MIME type against constraints.
140 ///
141 /// Throws [ImageValidationException] if validation fails.
142 static void validateImage({
143 required Uint8List bytes,
144 required String mimeType,
145 required ImageConstraints constraints,
146 }) {
147 // Check file size
148 if (bytes.length > constraints.maxSizeBytes) {
149 final maxSizeMB = constraints.maxSizeBytes / (1024 * 1024);
150 throw ImageValidationException(
151 'Image size exceeds maximum of ${maxSizeMB.toStringAsFixed(0)} MB. '
152 'Please choose a smaller image.',
153 );
154 }
155
156 // Check MIME type
157 if (!constraints.allowedMimeTypes.contains(mimeType)) {
158 final allowed = constraints.allowedMimeTypes
159 .map((t) => t.split('/').last.toUpperCase())
160 .join(', ');
161 throw ImageValidationException(
162 'Unsupported image type. Please use $allowed.',
163 );
164 }
165 }
166}