From a98de84c05d1fac968bb0f6eda7f65bb488d2e07 Mon Sep 17 00:00:00 2001 From: Naresh Pratista <2141720057@student.polinema.ac.id> Date: Wed, 13 Nov 2024 11:08:42 +0700 Subject: [PATCH] Refactored EditProfileScreen, SettingsScreen, and UserAvatar widgets; updated image selection and processing logic; added file size validation and error handling. --- .../screens/edit_profile_screen.dart | 35 +- .../settings/screens/settings_screen.dart | 10 +- .../settings/widgets/user_avatar.dart | 321 +++++++++++++----- pubspec.yaml | 3 +- 4 files changed, 261 insertions(+), 108 deletions(-) diff --git a/lib/features/settings/modules/edit_profile/screens/edit_profile_screen.dart b/lib/features/settings/modules/edit_profile/screens/edit_profile_screen.dart index cc47196..5769f47 100644 --- a/lib/features/settings/modules/edit_profile/screens/edit_profile_screen.dart +++ b/lib/features/settings/modules/edit_profile/screens/edit_profile_screen.dart @@ -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 { super.dispose(); } - Future _pickImage() async { - final pickedFile = - await ImagePicker().pickImage(source: ImageSource.gallery); - if (pickedFile != null) { - final userProvider = Provider.of(context, listen: false); - userProvider.setSelectedImage(File(pickedFile.path)); - } - } - Future _updateUserProfile(BuildContext context) async { final userProvider = Provider.of(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 { }, ); } 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 { 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, ), ), ], diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index e5046f4..7e8b0be 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -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 { ), 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 { 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( diff --git a/lib/features/settings/widgets/user_avatar.dart b/lib/features/settings/widgets/user_avatar.dart index fadc1d0..c06d37b 100644 --- a/lib/features/settings/widgets/user_avatar.dart +++ b/lib/features/settings/widgets/user_avatar.dart @@ -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 _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 _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, + ), + ), + ], + ), + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index d7fb67e..337fb43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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