311 lines
9.3 KiB
Dart
311 lines
9.3 KiB
Dart
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:bootstrap_icons/bootstrap_icons.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:english_learning/core/utils/styles/theme.dart';
|
|
import 'package:english_learning/core/widgets/custom_snackbar.dart';
|
|
|
|
class UserAvatar extends StatelessWidget {
|
|
final String? pictureUrl;
|
|
final double radius;
|
|
final String baseUrl;
|
|
final Function(File) onImageSelected;
|
|
final File? selectedImage;
|
|
final bool showCameraIcon;
|
|
final bool isLoading;
|
|
static const int _maxSizeInBytes = 5 * 1024 * 1024; // 5MB in bytes
|
|
static const String _maxSizeFormatted = '5MB';
|
|
|
|
const UserAvatar({
|
|
super.key,
|
|
this.pictureUrl,
|
|
this.radius = 55,
|
|
required this.baseUrl,
|
|
required this.onImageSelected,
|
|
this.selectedImage,
|
|
this.showCameraIcon = false,
|
|
this.isLoading = false,
|
|
});
|
|
|
|
String _formatFileSize(int bytes) {
|
|
if (bytes <= 0) return '0 B';
|
|
const suffixes = ['B', 'KB', 'MB', 'GB'];
|
|
var i = (log(bytes) / log(1024)).floor();
|
|
return '${(bytes / pow(1024, i)).toStringAsFixed(1)} ${suffixes[i]}';
|
|
}
|
|
|
|
Future<void> _handleImageSelection(BuildContext context) async {
|
|
try {
|
|
final ImagePicker picker = ImagePicker();
|
|
|
|
// Show bottom sheet for image source selection
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.white,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (BuildContext context) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Select Image Source',
|
|
style: AppTextStyles.blackTextStyle.copyWith(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_ImageSourceOption(
|
|
icon: BootstrapIcons.camera,
|
|
label: 'Camera',
|
|
onTap: () async {
|
|
Navigator.pop(context);
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 80,
|
|
maxWidth: 1000,
|
|
maxHeight: 1000,
|
|
);
|
|
if (image != null) {
|
|
_processSelectedImage(context, File(image.path));
|
|
}
|
|
},
|
|
),
|
|
_ImageSourceOption(
|
|
icon: BootstrapIcons.image,
|
|
label: 'Gallery',
|
|
onTap: () async {
|
|
Navigator.pop(context);
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
imageQuality: 80,
|
|
maxWidth: 1000,
|
|
maxHeight: 1000,
|
|
);
|
|
if (image != null) {
|
|
_processSelectedImage(context, File(image.path));
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
CustomSnackBar.show(
|
|
context,
|
|
message: 'Failed to select image. Please try again.',
|
|
isError: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _processSelectedImage(BuildContext context, File image) async {
|
|
try {
|
|
final fileSize = await image.length();
|
|
|
|
if (fileSize > _maxSizeInBytes) {
|
|
final actualSize = _formatFileSize(fileSize);
|
|
CustomSnackBar.show(
|
|
context,
|
|
message:
|
|
'Selected image size ($actualSize) exceeds maximum limit of $_maxSizeFormatted',
|
|
isError: true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
onImageSelected(image);
|
|
} catch (e) {
|
|
CustomSnackBar.show(
|
|
context,
|
|
message: 'Error processing image. Please try again.',
|
|
isError: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showFullScreenImage(BuildContext context) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
fullscreenDialog: true,
|
|
builder: (context) => Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
body: Container(
|
|
color: Colors.black,
|
|
child: Center(
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 4.0,
|
|
child: Hero(
|
|
tag: 'profile_image',
|
|
child: _getFullScreenContent(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _getFullScreenContent() {
|
|
if (selectedImage != null) {
|
|
return Image.file(selectedImage!, fit: BoxFit.contain);
|
|
} else if (pictureUrl != null && pictureUrl!.isNotEmpty) {
|
|
return Image.network(
|
|
'$baseUrl$pictureUrl',
|
|
fit: BoxFit.contain,
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
if (loadingProgress == null) return child;
|
|
return const Center(child: CircularProgressIndicator());
|
|
},
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return _buildDefaultIcon(size: 120, color: Colors.white);
|
|
},
|
|
);
|
|
} else {
|
|
return _buildDefaultIcon(size: 120, color: Colors.white);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => _showFullScreenImage(context),
|
|
child: Hero(
|
|
tag: 'profile_image',
|
|
child: CircleAvatar(
|
|
radius: radius,
|
|
backgroundColor: AppColors.primaryColor,
|
|
child: isLoading
|
|
? const CircularProgressIndicator(color: Colors.white)
|
|
: _getAvatarContent(),
|
|
),
|
|
),
|
|
),
|
|
if (showCameraIcon && !isLoading)
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: GestureDetector(
|
|
onTap: () => _handleImageSelection(context),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryColor,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
),
|
|
child: Icon(
|
|
BootstrapIcons.camera,
|
|
color: AppColors.whiteColor,
|
|
size: radius * 0.3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _getAvatarContent() {
|
|
if (selectedImage != null) {
|
|
return ClipOval(
|
|
child: Image.file(
|
|
selectedImage!,
|
|
fit: BoxFit.cover,
|
|
width: radius * 2,
|
|
height: radius * 2,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return _buildDefaultIcon();
|
|
},
|
|
),
|
|
);
|
|
} else if (pictureUrl != null && pictureUrl!.isNotEmpty) {
|
|
return ClipOval(
|
|
child: Image.network(
|
|
'$baseUrl$pictureUrl',
|
|
fit: BoxFit.cover,
|
|
width: radius * 2,
|
|
height: radius * 2,
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
if (loadingProgress == null) return child;
|
|
return const CircularProgressIndicator(color: Colors.white);
|
|
},
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return _buildDefaultIcon();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
return _buildDefaultIcon();
|
|
}
|
|
|
|
Widget _buildDefaultIcon({double? size, Color? color}) {
|
|
return Icon(
|
|
BootstrapIcons.person,
|
|
color: color ?? AppColors.whiteColor,
|
|
size: size ?? radius * 1.2,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImageSourceOption extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
const _ImageSourceOption({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 32, color: AppColors.primaryColor),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
label,
|
|
style: AppTextStyles.blackTextStyle.copyWith(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|