Refactored EditProfileScreen, SettingsScreen, and UserAvatar widgets; updated image selection and processing logic; added file size validation and error handling.

This commit is contained in:
Naresh Pratista 2024-11-13 11:08:42 +07:00
parent d1e9d790b4
commit a98de84c05
4 changed files with 261 additions and 108 deletions

View File

@ -8,7 +8,6 @@ import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/settings/modules/edit_profile/widgets/save_changes_dialog.dart';
import 'package:english_learning/features/settings/widgets/user_avatar.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
class EditProfileScreen extends StatefulWidget {
@ -42,18 +41,19 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
super.dispose();
}
Future<void> _pickImage() async {
final pickedFile =
await ImagePicker().pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
final userProvider = Provider.of<UserProvider>(context, listen: false);
userProvider.setSelectedImage(File(pickedFile.path));
}
}
Future<void> _updateUserProfile(BuildContext context) async {
final userProvider = Provider.of<UserProvider>(context, listen: false);
// Validasi input
if (_nameController.text.isEmpty || _emailController.text.isEmpty) {
CustomSnackBar.show(
context,
message: 'Please fill all required fields',
isError: true,
);
return;
}
final updatedData = {
'NAME_USERS': _nameController.text,
'EMAIL': _emailController.text,
@ -77,9 +77,13 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
},
);
} catch (e) {
CustomSnackBar.show(context,
message: 'Failed to update profile. Please try again.',
isError: true);
userProvider.setLoading(false);
CustomSnackBar.show(
context,
message: 'Failed to update profile. Please try again.',
isError: true,
);
}
}
@ -130,9 +134,12 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
radius: 60,
pictureUrl: userProvider.userData?['PICTURE'],
baseUrl: '$baseUrl/uploads/avatar/',
onImageSelected: _pickImage,
onImageSelected: (File image) {
userProvider.setSelectedImage(image);
},
selectedImage: userProvider.selectedImage,
showCameraIcon: true,
isLoading: userProvider.isLoading,
),
),
],

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/widgets/custom_snackbar.dart';
@ -51,7 +53,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: Padding(
padding: const EdgeInsets.only(
top: 71.0, left: 26, right: 16.0, bottom: 29.0),
top: 71.0, left: 26, right: 16.0, bottom: 19.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -60,8 +62,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
Row(
children: [
UserAvatar(
radius: 60,
pictureUrl: userProvider.userData?['PICTURE'],
baseUrl: '$baseUrl/uploads/avatar/',
onImageSelected: (File image) {
userProvider.setSelectedImage(image);
},
selectedImage: userProvider.selectedImage,
isLoading: userProvider.isLoading,
),
const SizedBox(width: 28),
Column(

View File

@ -1,94 +1,222 @@
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()? onImageSelected;
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,
this.onImageSelected,
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: [
// Avatar with detail view
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
child: Container(
width: double.infinity,
height: 400,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: _getDetailAvatarContent(),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryColor,
),
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
),
],
),
),
);
},
);
},
child: CircleAvatar(
radius: radius,
backgroundColor: AppColors.primaryColor,
child: _getAvatarContent(),
onTap: () => _showFullScreenImage(context),
child: Hero(
tag: 'profile_image',
child: CircleAvatar(
radius: radius,
backgroundColor: AppColors.primaryColor,
child: isLoading
? const CircularProgressIndicator(color: Colors.white)
: _getAvatarContent(),
),
),
),
// Camera icon
if (showCameraIcon)
if (showCameraIcon && !isLoading)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onTap: onImageSelected,
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,
),
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
BootstrapIcons.camera,
@ -110,6 +238,9 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover,
width: radius * 2,
height: radius * 2,
errorBuilder: (context, error, stackTrace) {
return _buildDefaultIcon();
},
),
);
} else if (pictureUrl != null && pictureUrl!.isNotEmpty) {
@ -119,53 +250,61 @@ class UserAvatar extends StatelessWidget {
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) {
print('Error loading avatar image: $error');
return _buildDefaultIcon();
},
),
);
} else {
return _buildDefaultIcon();
}
return _buildDefaultIcon();
}
Widget _getDetailAvatarContent() {
if (selectedImage != null) {
return Image.file(
selectedImage!,
fit: BoxFit.cover,
);
} else if (pictureUrl != null && pictureUrl!.isNotEmpty) {
return Image.network(
'$baseUrl$pictureUrl',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
BootstrapIcons.person,
color: AppColors.primaryColor,
size: 120,
),
);
},
);
} else {
return Center(
child: Icon(
BootstrapIcons.person,
color: AppColors.primaryColor,
size: 120,
),
);
}
}
Widget _buildDefaultIcon() {
Widget _buildDefaultIcon({double? size, Color? color}) {
return Icon(
BootstrapIcons.person,
color: AppColors.whiteColor,
size: radius * 1.2,
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,
),
),
],
),
),
);
}
}

View File

@ -54,12 +54,11 @@ dependencies:
youtube_player_flutter: ^9.1.1
dev_dependencies:
flutter_launcher_icons: ^0.14.1
flutter_lints: ^4.0.0
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.14.1
flutter_launcher_icons:
android: true
ios: true