# CloudMedia v0.0.2
A complete Firebase-native media management Flutter package.
One line to pick. One model returned. Everything else automated.
**Now with ONNX-powered background removal, video thumbnails, and native image cropping!**
```dart
final items = await CloudMedia.pick();
Table of Contents
- New in v0.0.2
- Installation
- Setup
- Responsive UI with ScreenUtil
- Firebase Setup & Examples
- Images
- Image Cropping
- Videos
- Audio
- Files & PDF
- List & Filter
- Upload & Sync Watching
- Delete & Restore
- Download & Share
- Cache
- UI Widgets
- Screens
- Dialogs
- Riverpod Providers
- Error Handling
- Background Removal
- Image Compression
- Permissions
- Utilities
- CloudMediaItem Model
- Status Lifecycle
- Configuration
- Platform Support
- What CloudMedia Handles Automatically
- Troubleshooting
- Custom Paths & Collections
- 1. Image — Custom Path & Collection
- 2. Video — Custom Path & Collection
- 3. Audio — Custom Path & Collection
- 4. PDF — Custom Path & Collection
- 5. Multiple Files — Batch Upload
- 6. With Background Removal — Custom Path
- 7. With Cropping — Custom Path
- 8. Offline Queue — Custom Collection
- 9. Read Back from Custom Collection
- 10. Core Upload Helper
- Custom Path Patterns Reference
- License
New in v0.0.2
- ✅ Background Removal — On-device ONNX model (
image_background_remover) with 30s timeout and progress indicator - ✅ Video Thumbnails — Automatic video previews (
flutter_video_thumbnail_plus) - ✅ Image Cropping — Full native cropping UI (
image_cropper) with rectangle/circle support - ✅ Improved Performance — Faster thumbnail generation with square crop
- ✅ Better Error Handling — 13 typed exceptions covering all scenarios
- ✅ Enhanced Documentation — Full API reference with examples
- ✅ Responsive UI — Built-in
flutter_screenutilsupport for all widgets
Installation
Add to your pubspec.yaml:
dependencies:
cloud_media:
path: ../cloud_media
# or from git:
# git:
# url: https://github.com/yourusername/cloud_media.git
# Required for responsive UI
flutter_screenutil: ^5.9.3
Then run:
flutter pub get
Setup
Android — android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
iOS — ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Required to pick photos and videos</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Required to access your media library</string>
<key>NSMicrophoneUsageDescription</key>
<string>Required to record audio</string>
Responsive UI with ScreenUtil
All CloudMedia widgets support flutter_screenutil for responsive sizing across different screen sizes.
Setup ScreenUtil in your app
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize ScreenUtil before runApp
await ScreenUtil.ensureScreenSize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812), // Design size (e.g., iPhone 13)
minTextAdapt: true,
splitScreenMode: true,
builder: (context, child) {
return MaterialApp(
title: 'My App',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: child,
);
},
child: const HomePage(),
);
}
}
ScreenUtil Methods Used in Widgets
| Method | Purpose | Example |
|---|---|---|
.w |
Width scaling (horizontal) | 16.w |
.h |
Height scaling (vertical) | 24.h |
.r |
Radius / Square scaling | 8.r |
.sp |
Font size scaling | 14.sp |
Example: Responsive CloudImage
// Width and height auto-scale with ScreenUtil
CloudImage(
media: item,
width: 200, // automatically scaled as 200.w
height: 200, // automatically scaled as 200.h
fit: BoxFit.cover,
)
// Without explicit dimensions - uses parent constraints
CloudImage(
media: item,
fit: BoxFit.cover,
)
Firebase Setup & Examples
Firebase Project Setup
-
Create a Firebase project at https://console.firebase.google.com
-
Register your app:
- Android: Add package name, download
google-services.json - iOS: Add bundle ID, download
GoogleService-Info.plist - Web: Add app, get Firebase config
- Android: Add package name, download
-
Enable Firebase services:
- Authentication (enable Anonymous or Email/Password)
- Firestore Database (create in test mode first)
- Storage (create in test mode first)
-
Add Firebase to your Flutter project:
flutter pub add firebase_core firebase_auth cloud_firestore firebase_storage
- Generate Firebase options:
dart run build_runner build --delete-conflicting-outputs
Firebase Authentication
Anonymous Sign-in (simplest for testing):
import 'package:firebase_auth/firebase_auth.dart';
Future<void> signInAnonymously() async {
try {
final userCredential = await FirebaseAuth.instance.signInAnonymously();
print('Signed in with: ${userCredential.user?.uid}');
} on FirebaseAuthException catch (e) {
print('Failed: ${e.message}');
}
}
Email/Password Sign-in:
Future<void> signInWithEmail(String email, String password) async {
try {
final userCredential = await FirebaseAuth.instance
.signInWithEmailAndPassword(email: email, password: password);
print('User: ${userCredential.user?.uid}');
} on FirebaseAuthException catch (e) {
if (e.code == 'user-not-found') {
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: password,
);
}
}
}
Google Sign-in:
import 'package:google_sign_in/google_sign_in.dart';
Future<void> signInWithGoogle() async {
final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
final GoogleSignInAuthentication googleAuth =
await googleUser!.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await FirebaseAuth.instance.signInWithCredential(credential);
}
Upload to Firebase
Automatic Upload (CloudMedia handles everything):
// Just pick - CloudMedia auto-uploads to Firebase!
final items = await CloudMedia.pick();
// The item automatically uploads to:
// Firestore: /users/{userId}/media/{mediaId}
// Storage: /users/{userId}/media/{mediaId}/{fileName}
// Watch for completion
CloudMedia.watch(items.first.id).listen((item) {
if (item.status == CloudMediaStatus.synced) {
print('Uploaded to: ${item.downloadUrl}');
}
});
Manual Upload Control:
// Force sync immediately
await CloudMedia.sync();
// Check pending uploads
final pending = await CloudMedia.getPendingCount();
print('$pending items uploading');
Custom Upload with Metadata:
// Pick with custom metadata
final items = await CloudMedia.pick();
// Add custom metadata after upload
final item = items.first;
await CloudMedia.updateMetadata(item.id, {
'category': 'profile',
'tags': ['vacation', 'summer'],
'location': 'Paris',
'customField': 'any value',
});
Get from Firebase
Get all user media from Firebase:
// List all media from Firestore
final allMedia = await CloudMedia.list();
print('Total items: ${allMedia.length}');
// List filtered
final images = await CloudMedia.list(
type: CloudMediaType.image,
limit: 20,
);
// Get single item by ID
final item = await CloudMedia.get('media_id_here');
print(item.downloadUrl);
Real-time listening with Stream:
// Listen to real-time updates from Firestore
CloudMedia.watch(mediaId).listen((item) {
print('Status: ${item.status}');
print('URL: ${item.downloadUrl}');
print('Thumbnail: ${item.thumbnailUrl}');
});
// Listen to multiple items
final watcher = CloudMediaWatcher();
watcher.watchMultiple([id1, id2, id3]).listen((items) {
print('${items.length} items updated');
});
Query Firestore directly (advanced):
import 'package:cloud_firestore/cloud_firestore.dart';
// Get Firestore instance
final firestore = FirebaseFirestore.instance;
final userId = FirebaseAuth.instance.currentUser!.uid;
// Direct query (same as CloudMedia.list())
final snapshot = await firestore
.collection('users')
.doc(userId)
.collection('media')
.where('deletedAt', isNull: true)
.orderBy('createdAt', descending: true)
.limit(50)
.get();
for (final doc in snapshot.docs) {
print(doc.data());
}
Delete from Firebase
Delete via CloudMedia (recommended):
// Delete single item (removes from Storage + Firestore)
await CloudMedia.delete(mediaId);
// Delete by item reference
await CloudMedia.deleteRef(item);
// Delete all images
final images = await CloudMedia.list(type: CloudMediaType.image);
for (final img in images) {
await CloudMedia.delete(img.id);
}
Restore deleted item:
// Restore soft-deleted item
await CloudMedia.restore(mediaId);
Direct Firebase delete (advanced):
// Delete from Storage and Firestore manually
final userId = FirebaseAuth.instance.currentUser!.uid;
// Delete from Storage
final storageRef = FirebaseStorage.instance
.ref('users/$userId/media/$mediaId/filename.jpg');
await storageRef.delete();
// Delete from Firestore
await FirebaseFirestore.instance
.collection('users')
.doc(userId)
.collection('media')
.doc(mediaId)
.delete();
Firebase Security Rules (Complete)
Firestore Security Rules — firestore.rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isAuthenticated() { return request.auth != null; }
function isOwner(userId) { return request.auth.uid == userId; }
match /users/{userId} {
allow read, write: if isAuthenticated() && isOwner(userId);
match /media/{mediaId} {
allow read: if isAuthenticated() && isOwner(userId);
allow write: if isAuthenticated() && isOwner(userId);
allow create: if isAuthenticated() && isOwner(userId)
&& request.resource.data.keys().hasAll([
'userId', 'type', 'fileName', 'mimeType',
'size', 'storagePath', 'status', 'createdAt'
]);
allow update: if isAuthenticated() && isOwner(userId)
&& request.resource.data.userId == resource.data.userId;
allow update: if resource.data.status == 'pending'
&& request.resource.data.status == 'syncing'
|| resource.data.status == 'syncing'
&& request.resource.data.status == 'synced'
|| resource.data.status == 'syncing'
&& request.resource.data.status == 'failed'
|| resource.data.status == 'synced'
&& request.resource.data.status == 'deleted';
}
}
}
}
Firebase Storage Rules — storage.rules:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function isAuthenticated() { return request.auth != null; }
function isOwner(userId) { return request.auth.uid == userId; }
match /users/{userId}/{allPaths=**} {
allow read, write: if isAuthenticated() && isOwner(userId);
allow create: if isAuthenticated() && isOwner(userId)
&& request.resource.size < 100 * 1024 * 1024
&& request.resource.contentType.matches('image/.*|video/.*|audio/.*|application/pdf');
match /thumbnails/{thumbnailId} {
allow create: if request.resource.size < 500 * 1024;
}
}
}
}
Firestore Indexes
Create these indexes in Firebase Console → Firestore → Indexes:
{
"collectionId": "media",
"fields": [
{"fieldPath": "deletedAt", "mode": "ASCENDING"},
{"fieldPath": "createdAt", "mode": "DESCENDING"}
]
}
{
"collectionId": "media",
"fields": [
{"fieldPath": "deletedAt", "mode": "ASCENDING"},
{"fieldPath": "type", "mode": "ASCENDING"},
{"fieldPath": "createdAt", "mode": "DESCENDING"}
]
}
Complete main.dart with Firebase & ScreenUtil
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:permission_handler_package/permission_handler_package.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';
import 'package:cloud_media/cloud_media.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ScreenUtil.ensureScreenSize();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await FirebaseAuth.instance.signInAnonymously();
await PermissionHandler.initialize();
await OfflineSyncLayer.instance.initialize(
config: const SyncConfig(
autoSyncOnReconnect: true,
syncImmediately: true,
maxConcurrentOperations: 2,
enableMetrics: true,
enableDebugLogging: false,
syncOnWiFiOnly: false,
maxRetries: 5,
initialRetryDelay: Duration(seconds: 2),
maxQueueSize: 500,
),
);
await CloudMedia.initialize(
config: const CloudMediaConfig(
maxCacheSizeMb: 500,
imageQuality: 85,
thumbnailSize: 200,
maxSelection: 20,
enableOfflineSync: true,
enableReviewScreen: true,
enableBackgroundRemoval: true,
enableLogging: true,
compressAutomatically: true,
autoGenerateThumbnails: true,
),
);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
builder: (context, child) {
return MaterialApp(
title: 'CloudMedia Demo',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: child,
debugShowCheckedModeBanner: false,
);
},
child: const HomePage(),
);
}
}
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
List<CloudMediaItem> _mediaItems = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadMedia();
}
Future<void> _loadMedia() async {
setState(() => _loading = true);
final items = await CloudMedia.list();
setState(() {
_mediaItems = items;
_loading = false;
});
}
Future<void> _pickAndUpload() async {
try {
final items = await CloudMedia.pick();
if (items.isNotEmpty) {
setState(() {
_mediaItems.insertAll(0, items);
});
for (final item in items) {
CloudMedia.watch(item.id).listen((updated) {
if (updated.status == CloudMediaStatus.synced) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Uploaded: ${updated.fileName}')),
);
_loadMedia();
}
});
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
}
Future<void> _deleteFromFirebase(CloudMediaItem item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete'),
content: Text('Delete "${item.fileName}" from Firebase?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await CloudMedia.delete(item.id);
setState(() => _mediaItems.removeWhere((i) => i.id == item.id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Deleted from Firebase')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: $e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('CloudMedia Demo', style: TextStyle(fontSize: 18.sp)),
actions: [
SyncStatusIndicator(showLabel: false),
IconButton(
icon: Icon(Icons.refresh, size: 20.r),
onPressed: _loadMedia,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _mediaItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_library, size: 64.r, color: Colors.grey),
SizedBox(height: 16.h),
Text('No media yet. Tap + to upload!', style: TextStyle(fontSize: 14.sp)),
],
),
)
: MediaGrid(
mediaItems: _mediaItems,
onItemTap: (item) => _showMediaDetail(item),
onItemLongPress: (item) => _deleteFromFirebase(item),
),
floatingActionButton: PermissionAwareMediaPicker(
mediaType: CloudMediaType.image,
maxCount: 5,
onMediaSelected: (_) => _pickAndUpload(),
child: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.add, size: 24.r),
),
),
);
}
void _showMediaDetail(CloudMediaItem item) {
showModalBottomSheet(
context: context,
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.cloud_download),
title: const Text('Download from Firebase'),
subtitle: Text(item.downloadUrl.isNotEmpty ? 'URL available' : 'Still uploading...'),
onTap: () async {
if (item.downloadUrl.isNotEmpty) {
final path = await CloudMedia.download(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloaded to: $path')),
);
}
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share'),
onTap: () async => await CloudMedia.share(item.id),
),
ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: const Text('Delete from Firebase'),
onTap: () {
Navigator.pop(context);
_deleteFromFirebase(item);
},
),
ListTile(
leading: const Icon(Icons.info),
title: Text('Firestore ID: ${item.id.substring(0, 8)}...'),
subtitle: Text('Status: ${item.status.displayName}'),
),
],
),
);
}
}
Images
Pick single image
final items = await CloudMedia.pick();
final image = items.first;
Pick multiple images
final items = await CloudMedia.pick(
type: CloudMediaType.image,
maxCount: 10,
);
Pick with crop and rotate
final items = await CloudMedia.pick(
type: CloudMediaType.image,
enableEditing: true,
);
Pick with background removal
final items = await CloudMedia.pick(
type: CloudMediaType.image,
enableBackgroundRemoval: true,
);
Display image (responsive)
CloudImage(
media: item,
width: 200, // auto-scaled with ScreenUtil
height: 200, // auto-scaled with ScreenUtil
fit: BoxFit.cover,
)
Display with zoom
CloudImage(
media: item,
enableZoom: true,
)
Display with Hero animation
Hero(
tag: 'img_${item.id}',
child: CloudImage(media: item),
)
Get all images
final images = await CloudMedia.list(
type: CloudMediaType.image,
);
Get recent images (last 7 days)
final recent = await CloudMedia.list(
type: CloudMediaType.image,
startDate: DateTime.now().subtract(const Duration(days: 7)),
);
Image Cropping
Crop after picking (built-in)
final items = await CloudMedia.pick(
type: CloudMediaType.image,
enableEditing: true,
);
Manual cropping dialog
// Square crop (1:1) — perfect for profile pictures
final cropped = await CroppingDialog.showSquareCrop(
context: context,
imagePath: '/path/to/image.jpg',
);
// Circle crop — perfect for avatars
final cropped = await CroppingDialog.showCircleCrop(
context: context,
imagePath: '/path/to/image.jpg',
);
// Wide screen crop (16:9)
final cropped = await CroppingDialog.showWideScreenCrop(
context: context,
imagePath: '/path/to/image.jpg',
);
// Custom aspect ratio
final cropped = await CroppingDialog.show(
context: context,
imagePath: '/path/to/image.jpg',
cropStyle: CropStyle.rectangle,
aspectRatio: 16 / 9,
allowRotation: true,
);
if (cropped != null) {
print('Cropped image saved to: ${cropped.path}');
final size = await cropped.length();
print('File size: ${FileUtils.formatFileSize(size)}');
}
CroppingDialog API
| Method | Description |
|---|---|
CroppingDialog.show() |
Full customizable crop dialog |
CroppingDialog.showSquareCrop() |
1:1 aspect ratio (profile pictures) |
CroppingDialog.showCircleCrop() |
Circular crop (avatars) |
CroppingDialog.showWideScreenCrop() |
16:9 aspect ratio (banners) |
Videos
Pick single video
final items = await CloudMedia.pick(
type: CloudMediaType.video,
);
Pick with editing
final items = await CloudMedia.pick(
type: CloudMediaType.video,
enableEditing: true,
);
Display video player (responsive)
CloudVideo(
media: item,
width: double.infinity,
height: 300, // auto-scaled with ScreenUtil
autoPlay: true,
showControls: true,
)
Display as thumbnail (no controls)
CloudVideo(
media: item,
showControls: false,
height: 150,
)
Full width player
CloudVideo(
media: item,
width: MediaQuery.of(context).size.width,
showControls: true,
)
Get all videos
final videos = await CloudMedia.list(
type: CloudMediaType.video,
);
Video Thumbnails (Automatic)
final items = await CloudMedia.pick(type: CloudMediaType.video);
CloudVideo(media: items.first);
Audio
Pick single audio file
final items = await CloudMedia.pick(
type: CloudMediaType.audio,
);
Pick multiple audio files
final items = await CloudMedia.pick(
type: CloudMediaType.audio,
maxCount: 5,
);
Display audio player
CloudAudio(
media: item,
autoPlay: false,
)
Auto-play audio
CloudAudio(
media: item,
autoPlay: true,
)
Get all audio files
final audio = await CloudMedia.list(
type: CloudMediaType.audio,
);
Files & PDF
Pick PDF
final items = await CloudMedia.pick(
type: CloudMediaType.file,
);
Display file tile
CloudFile(
media: item,
onDownload: () async => await CloudMedia.download(item.id),
onShare: () async => await CloudMedia.share(item.id),
)
Get all documents
final docs = await CloudMedia.list(
type: CloudMediaType.file,
);
List & Filter
All media
final all = await CloudMedia.list();
First 20 items
final recent = await CloudMedia.list(limit: 20);
Filter by type
final images = await CloudMedia.list(
type: CloudMediaType.image,
);
Filter by date range
final filtered = await CloudMedia.list(
startDate: DateTime(2025, 1, 1),
endDate: DateTime.now(),
);
Pagination
final page1 = await CloudMedia.list(limit: 20, offset: 0);
final page2 = await CloudMedia.list(limit: 20, offset: 20);
Search
final results = await CloudMedia.list(
searchQuery: 'vacation',
);
Using CloudMediaListExtension
final options = CloudMediaListOptions(
type: CloudMediaType.image,
limit: 20,
startDate: DateTime(2025, 1, 1),
);
final items = await CloudMedia.listWithOptions(options);
final images = await CloudMedia.listByType(CloudMediaType.image, limit: 30);
final recent = await CloudMedia.listRecent(limit: 10);
Upload & Sync Watching
Watch single item
CloudMedia.watch(item.id).listen((updated) {
print(updated.status.displayName);
print(updated.downloadUrl);
});
Watch with helper class
final watcher = CloudMediaWatcher();
final synced = await watcher.watchUntil(
item.id,
CloudMediaStatus.synced,
timeout: const Duration(minutes: 2),
);
watcher.watchUploadProgress(item.id).listen((progress) {
print('${(progress * 100).toStringAsFixed(0)}%');
});
watcher.watchStatus(item.id).listen((status) {
print(status.displayName);
});
watcher.watchMultiple([id1, id2, id3]).listen((items) {
print('${items.length} items updated');
});
watcher.dispose();
Force sync & pending count
await CloudMedia.sync();
final pending = await CloudMedia.getPendingCount();
Delete & Restore
Delete by ID
await CloudMedia.delete(item.id);
Delete by item reference
await CloudMedia.deleteRef(item);
Delete all images
final images = await CloudMedia.list(type: CloudMediaType.image);
for (final item in images) {
await CloudMedia.delete(item.id);
}
Restore soft-deleted item
await CloudMedia.restore(item.id);
Download & Share
Download to device
final localPath = await CloudMedia.download(item.id);
print('Saved to: $localPath');
Share via platform sheet
await CloudMedia.share(item.id);
Get single item
final item = await CloudMedia.get(mediaId);
print(item.fileName);
print(item.status.displayName);
print(item.type.displayName);
Cache
Clear all cache
await CloudMedia.clearCache();
Get cache size
final cacheService = CacheService(config: const CloudMediaConfig());
await cacheService.initialize();
final size = await cacheService.getCacheSize();
print(FileUtils.formatFileSize(size));
UI Widgets
Media grid (responsive)
MediaGrid(
mediaItems: items,
crossAxisCount: 3,
crossAxisSpacing: 4, // auto-scaled with ScreenUtil
mainAxisSpacing: 4, // auto-scaled with ScreenUtil
onItemTap: (item) => print('tapped: ${item.id}'),
onItemLongPress: (item) => print('long pressed: ${item.id}'),
)
Upload progress bar
UploadProgress(
uploadId: item.id,
showDetails: true,
)
Upload progress with pause/resume/cancel
UploadProgressIndicator(
mediaId: item.id,
onComplete: () => print('Done!'),
)
Sync status indicator
SyncStatusIndicator(showLabel: false) // AppBar
SyncStatusIndicator(showLabel: true) // Inline
SyncStatusIndicator(showAsFloatingAction: true) // FAB
Permission-aware picker widget
PermissionAwareMediaPicker(
mediaType: CloudMediaType.image,
maxCount: 5,
onMediaSelected: (files) {
print('Got ${files.length} files');
},
child: ElevatedButton(
onPressed: () {},
child: const Text('Pick Images'),
),
)
Screens
Media Library Screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaLibraryScreen(
type: CloudMediaType.image,
onMediaTap: (item) => print('tapped: ${item.id}'),
),
),
);
Review Screen
final items = await CloudMedia.pick();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReviewScreen(
mediaItems: items,
onConfirm: () => print('Confirmed!'),
onCancel: () => print('Cancelled'),
),
),
);
Dialogs
Error Dialog
await ErrorDialog.show(
context,
title: 'Upload Failed',
message: 'Check your connection and try again.',
onRetry: () => _retryUpload(),
onDismiss: () => print('dismissed'),
);
Loading Dialog
LoadingDialog.show(context, message: 'Uploading...');
LoadingDialog.hide(context);
LoadingDialog.show(
context,
message: 'Processing...',
showProgress: true,
progress: 0.65,
);
Confirmation Dialog
final confirmed = await ConfirmationDialog.show(
context,
title: 'Delete Media',
message: 'This cannot be undone.',
confirmText: 'Delete',
confirmColor: Colors.red,
);
if (confirmed == true) await CloudMedia.delete(item.id);
Riverpod Providers
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSyncing = ref.watch(isMediaSyncingProvider);
final pending = ref.watch(pendingMediaCountProvider);
final syncText = ref.watch(syncStatusTextProvider);
final isOnline = ref.watch(isConnectedProvider);
return Column(children: [
Text(syncText),
Text(isSyncing ? 'Uploading...' : 'Idle'),
Text(isOnline ? 'Online' : 'Offline'),
]);
}
}
Error Handling
try {
final items = await CloudMedia.pick();
} on CloudMediaPermissionDeniedException {
print('Permission denied');
} on CloudMediaPermissionPermanentlyDeniedException {
await PermissionService.openSettings(ref);
} on CloudMediaFileTooLargeException catch (e) {
print(e.message);
} on CloudMediaUnsupportedFileTypeException catch (e) {
print(e.message);
} on CloudMediaSelectionLimitExceededException {
print('Too many files selected');
} on CloudMediaUploadFailedException {
print('Queued for offline sync');
} on CloudMediaNetworkException {
print('No internet — queued');
} catch (e) {
final message = ErrorHandler.getUserFriendlyMessage(e);
print(message);
}
Background Removal
Automatic in pick flow
final items = await CloudMedia.pick(
type: CloudMediaType.image,
enableBackgroundRemoval: true,
);
Manual screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BackgroundRemovalScreen(
media: item,
onComplete: (processedPath) => print('Result: $processedPath'),
),
),
);
Custom provider
class RemoveBgApiProvider implements BackgroundRemovalProvider {
@override
Future<void> initialize() async {}
@override
Future<File> removeBackground(File image) async { /* API call */ }
@override
void dispose() {}
}
Image Compression
Auto-compressed
final items = await CloudMedia.pick(type: CloudMediaType.image);
Manual compression
final compressionService = CompressionService(
config: const CloudMediaConfig(imageQuality: 85),
);
final compressedPath = await compressionService.compressImage('/path/to/image.jpg');
Permissions
Auto-handled
final items = await CloudMedia.pick();
Manual requests
await PermissionService.requestMediaPermissions(context, ref);
await PermissionService.requestCameraPermission(context, ref);
await PermissionService.requestStoragePermission(context, ref);
await PermissionService.requestMicrophonePermission(context, ref);
await PermissionService.openSettings(ref);
Utilities
File utils
FileUtils.formatFileSize(1024 * 1024);
FileUtils.getFileExtension('photo.jpg');
FileUtils.getMimeType('video.mp4');
await FileUtils.deleteFile('/path/to/file');
Validators
Validators.validateFileType('photo.jpg');
Validators.validateFileSize(bytes, CloudMediaType.image);
Validators.validateSelectionCount(5);
Validators.isValidUrl('https://example.com');
Date formatter
DateFormatter.formatDate(item.createdAt);
DateFormatter.formatDateTime(item.createdAt);
DateFormatter.formatTimeAgo(item.createdAt);
DateFormatter.formatDuration(Duration(seconds: 125));
Logger
CloudLogger.isEnabled = true;
CloudLogger.info('Upload started');
CloudLogger.debug('Compressed: 4.2MB → 1.1MB');
CloudLogger.error('Upload failed', error: e);
Platform utils
PlatformUtils.isAndroid;
PlatformUtils.isIOS;
PlatformUtils.isWeb;
PlatformUtils.platformName;
CloudMediaItem Model
final item = items.first;
item.id // unique media ID
item.userId // Firebase Auth UID
item.type // CloudMediaType.image / video / audio / file
item.fileName // original file name
item.mimeType // image/jpeg, video/mp4, etc.
item.size // file size in bytes
item.width // image/video width (nullable)
item.height // image/video height (nullable)
item.duration // audio/video duration (nullable)
item.storagePath // Firebase Storage path
item.downloadUrl // Firebase download URL
item.thumbnailUrl // thumbnail URL
item.status // CloudMediaStatus
item.localPath // local file path
item.createdAt // DateTime
item.metadata // Map<String, dynamic>
Status Lifecycle
pending → processing → syncing → synced → failed
| Status | Icon | Color | Meaning |
|---|---|---|---|
pending |
⏳ | Orange | Waiting in queue |
processing |
🔧 | Blue | Compressing, generating thumbnail |
syncing |
🔄 | Purple | Uploading to Firebase |
synced |
✅ | Green | Upload complete |
failed |
❌ | Red | Upload failed |
deleted |
🗑️ | Grey | Soft deleted |
Configuration
await CloudMedia.initialize(
config: const CloudMediaConfig(
maxCacheSizeMb: 500,
imageQuality: 85,
thumbnailSize: 200,
maxSelection: 20,
enableOfflineSync: true,
enableReviewScreen: true,
enableBackgroundRemoval: true,
compressAutomatically: true,
autoGenerateThumbnails: true,
uploadTimeout: Duration(minutes: 5),
maxRetries: 3,
enableLogging: false,
),
);
Platform Support
| Platform | Status |
|---|---|
| Android | ✅ Fully supported |
| iOS | ✅ Fully supported |
| macOS | ✅ Supported |
| Windows | ✅ Supported |
| Linux | ✅ Supported |
| Web | ⚠️ Partial |
What CloudMedia Handles Automatically
| Feature | Automated |
|---|---|
| Permission requests | ✅ |
| Media picker UI | ✅ |
| Review screen | ✅ |
| Image compression (WebP) | ✅ |
| Thumbnail generation | ✅ |
| Video thumbnail generation | ✅ |
| Firebase Storage upload | ✅ |
| Firestore metadata write | ✅ |
| Offline queue | ✅ |
| Auto sync on reconnect | ✅ |
| Status tracking | ✅ |
| LRU cache (500MB) | ✅ |
| 13 typed exceptions | ✅ |
| Background removal | ✅ |
| Image cropping | ✅ |
Troubleshooting
Background removal not working?
flutter:
assets:
- assets/models/u2net.onnx
Cropping dialog type conflict?
import 'package:image_cropper/image_cropper.dart' as image_cropper;
Video thumbnails not generating?
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Debug logging
CloudMedia.initialize(config: CloudMediaConfig(enableLogging: true));
CloudLogger.isDebugEnabled = true;
Custom Paths & Collections
Setup — Initialize Services Directly
import 'package:cloud_media/cloud_media.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:uuid/uuid.dart';
import 'dart:io';
1. Image — Custom Path & Collection
class CustomMediaService {
final _uploadService = UploadService(config: const CloudMediaConfig());
final _compressionService = CompressionService(
config: const CloudMediaConfig(imageQuality: 85),
);
final _thumbnailService = ThumbnailService(
config: const CloudMediaConfig(thumbnailSize: 200),
);
Future<void> uploadProfilePhoto({required String userId}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.image,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
final mediaId = const Uuid().v4();
final compressed = await _compressionService.compressImage(file.path);
final thumbPath = await _thumbnailService.generateThumbnail(compressed, CloudMediaType.image);
final storagePath = 'profiles/$userId/avatar/$mediaId.webp';
final thumbStoragePath = 'profiles/$userId/thumbnails/$mediaId.webp';
final downloadUrl = await _uploadToStorage(
filePath: compressed,
storagePath: storagePath,
contentType: 'image/webp',
);
String thumbUrl = '';
if (thumbPath != null) {
thumbUrl = await _uploadToStorage(
filePath: thumbPath,
storagePath: thumbStoragePath,
contentType: 'image/webp',
);
}
await FirebaseFirestore.instance
.collection('users')
.doc(userId)
.collection('profile')
.doc('avatar')
.set({
'mediaId': mediaId,
'userId': userId,
'downloadUrl': downloadUrl,
'thumbnailUrl': thumbUrl,
'storagePath': storagePath,
'fileName': file.name,
'mimeType': 'image/webp',
'size': await File(compressed).length(),
'type': 'image',
'createdAt': Timestamp.now(),
});
}
}
2. Video — Custom Path & Collection
Future<void> uploadPostVideo({
required String postId,
required String userId,
required String caption,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.video,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
final mediaId = const Uuid().v4();
final thumbPath = await _thumbnailService.generateThumbnail(
file.path,
CloudMediaType.video,
size: 400,
);
final storagePath = 'posts/$postId/videos/$mediaId/${file.name}';
final thumbStoragePath = 'posts/$postId/thumbnails/$mediaId.webp';
final downloadUrl = await _uploadToStorage(
filePath: file.path,
storagePath: storagePath,
contentType: file.mimeType,
);
String thumbUrl = '';
if (thumbPath != null) {
thumbUrl = await _uploadToStorage(
filePath: thumbPath,
storagePath: thumbStoragePath,
contentType: 'image/webp',
);
}
await FirebaseFirestore.instance
.collection('posts')
.doc(postId)
.collection('media')
.doc(mediaId)
.set({
'mediaId': mediaId,
'userId': userId,
'postId': postId,
'caption': caption,
'downloadUrl': downloadUrl,
'thumbnailUrl': thumbUrl,
'storagePath': storagePath,
'fileName': file.name,
'mimeType': file.mimeType,
'type': 'video',
'size': await File(file.path).length(),
'createdAt': Timestamp.now(),
});
}
3. Audio — Custom Path & Collection
Future<void> uploadTrack({
required String albumId,
required String userId,
required String trackName,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.audio,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
final mediaId = const Uuid().v4();
final storagePath = 'music/$userId/albums/$albumId/$mediaId/${file.name}';
final downloadUrl = await _uploadToStorage(
filePath: file.path,
storagePath: storagePath,
contentType: file.mimeType,
);
await FirebaseFirestore.instance
.collection('albums')
.doc(albumId)
.collection('tracks')
.doc(mediaId)
.set({
'mediaId': mediaId,
'userId': userId,
'albumId': albumId,
'trackName': trackName,
'fileName': file.name,
'downloadUrl': downloadUrl,
'storagePath': storagePath,
'mimeType': file.mimeType,
'size': await File(file.path).length(),
'type': 'audio',
'createdAt': Timestamp.now(),
});
}
4. PDF — Custom Path & Collection
Future<void> uploadDocument({
required String projectId,
required String userId,
required String documentTitle,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.file,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
final mediaId = const Uuid().v4();
final storagePath = 'projects/$projectId/documents/$mediaId/${file.name}';
final downloadUrl = await _uploadToStorage(
filePath: file.path,
storagePath: storagePath,
contentType: 'application/pdf',
);
await FirebaseFirestore.instance
.collection('projects')
.doc(projectId)
.collection('documents')
.doc(mediaId)
.set({
'mediaId': mediaId,
'userId': userId,
'projectId': projectId,
'title': documentTitle,
'fileName': file.name,
'downloadUrl': downloadUrl,
'storagePath': storagePath,
'mimeType': 'application/pdf',
'size': await File(file.path).length(),
'type': 'file',
'createdAt': Timestamp.now(),
});
}
5. Multiple Files — Batch Upload
Future<void> uploadMultipleImages({
required String galleryId,
required String userId,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.image,
maxCount: 10,
);
if (files.isEmpty) return;
final futures = files.map((file) async {
final mediaId = const Uuid().v4();
final compressed = await _compressionService.compressImage(file.path);
final thumbPath = await _thumbnailService.generateThumbnail(compressed, CloudMediaType.image);
final storagePath = 'galleries/$galleryId/images/$mediaId.webp';
final thumbStoragePath = 'galleries/$galleryId/thumbnails/$mediaId.webp';
final downloadUrl = await _uploadToStorage(
filePath: compressed,
storagePath: storagePath,
contentType: 'image/webp',
);
String thumbUrl = '';
if (thumbPath != null) {
thumbUrl = await _uploadToStorage(
filePath: thumbPath,
storagePath: thumbStoragePath,
contentType: 'image/webp',
);
}
return {
'mediaId': mediaId,
'userId': userId,
'galleryId': galleryId,
'downloadUrl': downloadUrl,
'thumbnailUrl': thumbUrl,
'storagePath': storagePath,
'fileName': file.name,
'size': await File(compressed).length(),
'type': 'image',
'createdAt': Timestamp.now(),
};
});
final results = await Future.wait(futures);
final batch = FirebaseFirestore.instance.batch();
for (final data in results) {
final ref = FirebaseFirestore.instance
.collection('galleries')
.doc(galleryId)
.collection('images')
.doc(data['mediaId'] as String);
batch.set(ref, data);
}
await batch.commit();
}
6. With Background Removal — Custom Path
Future<void> uploadProductImage({
required String productId,
required String userId,
required BuildContext context,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.image,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
String processedPath = file.path;
final mediaItem = CloudMediaItem(
id: const Uuid().v4(),
userId: userId,
type: CloudMediaType.image,
fileName: file.name,
mimeType: file.mimeType,
size: await File(file.path).length(),
storagePath: '',
downloadUrl: '',
thumbnailUrl: '',
status: CloudMediaStatus.pending,
createdAt: DateTime.now(),
localPath: file.path,
);
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BackgroundRemovalScreen(
media: mediaItem,
onComplete: (path) {
processedPath = path;
Navigator.pop(context);
},
),
),
);
final mediaId = const Uuid().v4();
final compressed = await _compressionService.compressImage(processedPath);
final storagePath = 'products/$productId/images/$mediaId.webp';
final thumbStoragePath = 'products/$productId/thumbnails/$mediaId.webp';
final downloadUrl = await _uploadToStorage(
filePath: compressed,
storagePath: storagePath,
contentType: 'image/webp',
);
final thumbPath = await _thumbnailService.generateThumbnail(compressed, CloudMediaType.image);
String thumbUrl = '';
if (thumbPath != null) {
thumbUrl = await _uploadToStorage(
filePath: thumbPath,
storagePath: thumbStoragePath,
contentType: 'image/webp',
);
}
await FirebaseFirestore.instance
.collection('products')
.doc(productId)
.collection('images')
.doc(mediaId)
.set({
'mediaId': mediaId,
'userId': userId,
'productId': productId,
'downloadUrl': downloadUrl,
'thumbnailUrl': thumbUrl,
'storagePath': storagePath,
'backgroundRemoved': true,
'type': 'image',
'createdAt': Timestamp.now(),
});
}
7. With Cropping — Custom Path
Future<void> uploadCroppedAvatar({
required String userId,
required BuildContext context,
}) async {
final files = await _uploadService.pickMedia(
type: CloudMediaType.image,
maxCount: 1,
);
if (files.isEmpty) return;
final file = files.first;
final cropped = await CroppingDialog.showSquareCrop(
context: context,
imagePath: file.path,
);
if (cropped == null) return;
final compressed = await _compressionService.compressImage(cropped.path);
final mediaId = const Uuid().v4();
final storagePath = 'users/$userId/avatar/$mediaId.webp';
final downloadUrl = await _uploadToStorage(
filePath: compressed,
storagePath: storagePath,
contentType: 'image/webp',
);
await FirebaseFirestore.instance
.collection('users')
.doc(userId)
.update({
'avatarUrl': downloadUrl,
'avatarPath': storagePath,
'avatarUpdatedAt': Timestamp.now(),
});
}
8. Offline Queue — Custom Collection
Future<void> uploadWithOfflineSupport({
required String postId,
required String userId,
required String filePath,
required String mimeType,
required String fileName,
}) async {
final mediaId = const Uuid().v4();
final storagePath = 'posts/$postId/media/$mediaId/$fileName';
OfflineSyncLayer.instance.registerOperationHandler(
'custom_post_upload',
(data) async {
final ref = FirebaseStorage.instance.ref(data['storagePath'] as String);
await ref.putFile(
File(data['filePath'] as String),
SettableMetadata(contentType: data['mimeType'] as String),
);
final downloadUrl = await ref.getDownloadURL();
await FirebaseFirestore.instance
.collection('posts')
.doc(data['postId'] as String)
.collection('media')
.doc(data['mediaId'] as String)
.set({
'mediaId': data['mediaId'],
'downloadUrl': downloadUrl,
'storagePath': data['storagePath'],
'fileName': data['fileName'],
'createdAt': Timestamp.now(),
});
},
);
await OfflineSyncLayer.instance.submitOperation(
category: 'custom_post_upload',
priority: 1,
idempotencyKey: 'post_upload_$mediaId',
data: {
'filePath': filePath,
'storagePath': storagePath,
'postId': postId,
'userId': userId,
'mediaId': mediaId,
'fileName': fileName,
'mimeType': mimeType,
},
);
}
9. Read Back from Custom Collection
Future<List<CloudMediaItem>> getPostMedia(String postId) async {
final snap = await FirebaseFirestore.instance
.collection('posts')
.doc(postId)
.collection('media')
.orderBy('createdAt', descending: true)
.get();
return snap.docs.map(CloudMediaItem.fromFirestore).toList();
}
Stream<List<CloudMediaItem>> watchPostMedia(String postId) {
return FirebaseFirestore.instance
.collection('posts')
.doc(postId)
.collection('media')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snap) => snap.docs.map(CloudMediaItem.fromFirestore).toList());
}
10. Core Upload Helper
Future<String> _uploadToStorage({
required String filePath,
required String storagePath,
required String contentType,
Map<String, String>? customMetadata,
}) async {
final ref = FirebaseStorage.instance.ref(storagePath);
await ref.putFile(
File(filePath),
SettableMetadata(
contentType: contentType,
customMetadata: {
'uploadedAt': DateTime.now().toIso8601String(),
...?customMetadata,
},
),
);
return ref.getDownloadURL();
}
Custom Path Patterns Reference
// Social
'posts/{postId}/media/{mediaId}'
'stories/{userId}/frames/{frameId}'
'users/{userId}/avatar/{mediaId}'
// E-commerce
'products/{productId}/images/{mediaId}'
'shops/{shopId}/banners/{mediaId}'
// Education
'courses/{courseId}/lessons/{lessonId}/videos/{mediaId}'
// Chat
'chats/{chatId}/attachments/{mediaId}'
'groups/{groupId}/media/{mediaId}'
// Business
'projects/{projectId}/documents/{mediaId}'
'invoices/{invoiceId}/attachments/{mediaId}'
// Music
'artists/{artistId}/albums/{albumId}/tracks/{mediaId}'
Dependencies
| Package | Version |
|---|---|
| firebase_core | ^4.10.0 |
| firebase_auth | ^6.5.2 |
| firebase_storage | ^13.4.2 |
| cloud_firestore | ^6.5.0 |
| image_picker | ^1.1.2 |
| file_picker | ^12.0.0-beta.5 |
| video_player | ^2.9.2 |
| audioplayers | ^6.1.0 |
| flutter_image_compress | ^2.3.0 |
| image | ^4.2.0 |
| image_background_remover | ^2.0.0 |
| flutter_video_thumbnail_plus | ^1.0.6 |
| image_cropper | ^11.0.0 |
| photo_view | ^0.15.0 |
| cached_network_image | ^3.4.1 |
| riverpod_offline_sync | ^1.0.5 |
| hive_flutter | ^1.1.0 |
| permission_handler_package | ^1.0.6 |
| share_plus | ^13.1.0 |
| intl | ^0.20.2 |
| flutter_screenutil | ^5.9.3 |
License
MIT
Made with ❤️ for Flutter community
## Summary of Changes Made
1. **Permissions section** - Updated all `PermissionService` calls to include `ref` parameter:
- `await PermissionService.requestMediaPermissions(context, ref);`
- `await PermissionService.requestCameraPermission(context, ref);`
- `await PermissionService.requestStoragePermission(context, ref);`
- `await PermissionService.requestMicrophonePermission(context, ref);`
- `await PermissionService.openSettings(ref);`
2. **Error Handling section** - Updated `openSettings` call:
- `await PermissionService.openSettings(ref);`
3. **Complete `main.dart` example** - Changed `HomePage` from `StatefulWidget` to `ConsumerStatefulWidget`:
- `class HomePage extends ConsumerStatefulWidget`
- `class _HomePageState extends ConsumerState<HomePage>`
- Added `ref` parameter to `PermissionService` calls (none were present in this section as they were already properly using `ref`)
All other content remains exactly as it was in the original README. Only the specific permission-related methods were updated to include the required `ref` parameter.
Libraries
- api/cloud_media_api
- api/cloud_media_list
- api/cloud_media_watch
- cloud_media
- constants/error_messages
- constants/file_constants
- constants/firestore_paths
- constants/ui_constants
- models/cache_entry
- models/cloud_media_config
- models/cloud_media_item
- models/cloud_media_status
- models/cloud_media_type
- models/upload_task
- providers/cloud_media_provider
- providers/media_provider
- providers/sync_providers
- providers/upload_provider
- services/cache_service
- services/compression_service
- services/firebase_service
- services/offline_sync_service
- services/permission_service
- services/thumbnail_service
- services/upload_service
- ui/dialogs/confirmation_dialog
- ui/dialogs/cropping_dialog
- ui/dialogs/error_dialog
- ui/dialogs/loading_dialog
- ui/screens/background_removal_screen
- ui/screens/editor_screen
- ui/screens/media_library_screen
- ui/screens/media_picker_screen
- ui/screens/review_screen
- ui/widgets/cloud_audio
- ui/widgets/cloud_file
- ui/widgets/cloud_image
- ui/widgets/cloud_video
- ui/widgets/media_grid
- ui/widgets/permission_aware_media_picker
- ui/widgets/sync_status_indicator
- ui/widgets/upload_progress
- ui/widgets/upload_progress_indicator
- utils/date_formatter
- utils/error_handler
- utils/file_utils
- utils/logger
- utils/platform_utils
- utils/validators