Merge branch 'profile-upload-limit' into 'master'

Refactored EditProfileScreen, SettingsScreen, and UserAvatar widgets; updated...

See merge request profile-image/kedaireka/polinema-adapative-learning/mobile-adaptive-learning!13
This commit is contained in:
Naresh Pratista 2024-11-13 04:10:11 +00:00
commit 8a2aa421e8
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/modules/edit_profile/widgets/save_changes_dialog.dart';
import 'package:english_learning/features/settings/widgets/user_avatar.dart'; import 'package:english_learning/features/settings/widgets/user_avatar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EditProfileScreen extends StatefulWidget { class EditProfileScreen extends StatefulWidget {
@ -42,18 +41,19 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
super.dispose(); 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 { Future<void> _updateUserProfile(BuildContext context) async {
final userProvider = Provider.of<UserProvider>(context, listen: false); 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 = { final updatedData = {
'NAME_USERS': _nameController.text, 'NAME_USERS': _nameController.text,
'EMAIL': _emailController.text, 'EMAIL': _emailController.text,
@ -77,9 +77,13 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
}, },
); );
} catch (e) { } catch (e) {
CustomSnackBar.show(context, userProvider.setLoading(false);
message: 'Failed to update profile. Please try again.',
isError: true); CustomSnackBar.show(
context,
message: 'Failed to update profile. Please try again.',
isError: true,
);
} }
} }
@ -130,9 +134,12 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
radius: 60, radius: 60,
pictureUrl: userProvider.userData?['PICTURE'], pictureUrl: userProvider.userData?['PICTURE'],
baseUrl: '$baseUrl/uploads/avatar/', baseUrl: '$baseUrl/uploads/avatar/',
onImageSelected: _pickImage, onImageSelected: (File image) {
userProvider.setSelectedImage(image);
},
selectedImage: userProvider.selectedImage, selectedImage: userProvider.selectedImage,
showCameraIcon: true, showCameraIcon: true,
isLoading: userProvider.isLoading,
), ),
), ),
], ],

View File

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

View File

@ -1,94 +1,222 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:bootstrap_icons/bootstrap_icons.dart'; import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:flutter/material.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/utils/styles/theme.dart';
import 'package:english_learning/core/widgets/custom_snackbar.dart';
class UserAvatar extends StatelessWidget { class UserAvatar extends StatelessWidget {
final String? pictureUrl; final String? pictureUrl;
final double radius; final double radius;
final String baseUrl; final String baseUrl;
final Function()? onImageSelected; final Function(File) onImageSelected;
final File? selectedImage; final File? selectedImage;
final bool showCameraIcon; final bool showCameraIcon;
final bool isLoading;
static const int _maxSizeInBytes = 5 * 1024 * 1024; // 5MB in bytes
static const String _maxSizeFormatted = '5MB';
const UserAvatar({ const UserAvatar({
super.key, super.key,
this.pictureUrl, this.pictureUrl,
this.radius = 55, this.radius = 55,
required this.baseUrl, required this.baseUrl,
this.onImageSelected, required this.onImageSelected,
this.selectedImage, this.selectedImage,
this.showCameraIcon = false, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return Stack(
children: [ children: [
// Avatar with detail view
GestureDetector( GestureDetector(
onTap: () { onTap: () => _showFullScreenImage(context),
showDialog( child: Hero(
context: context, tag: 'profile_image',
builder: (BuildContext context) { child: CircleAvatar(
return Dialog( radius: radius,
child: Container( backgroundColor: AppColors.primaryColor,
width: double.infinity, child: isLoading
height: 400, ? const CircularProgressIndicator(color: Colors.white)
decoration: BoxDecoration( : _getAvatarContent(),
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(),
), ),
), ),
// Camera icon if (showCameraIcon && !isLoading)
if (showCameraIcon)
Positioned( Positioned(
right: 0, right: 0,
bottom: 0, bottom: 0,
child: GestureDetector( child: GestureDetector(
onTap: onImageSelected, onTap: () => _handleImageSelection(context),
child: Container( child: Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primaryColor, color: AppColors.primaryColor,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(color: Colors.white, width: 2),
color: Colors.white,
width: 2,
),
), ),
child: Icon( child: Icon(
BootstrapIcons.camera, BootstrapIcons.camera,
@ -110,6 +238,9 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
width: radius * 2, width: radius * 2,
height: radius * 2, height: radius * 2,
errorBuilder: (context, error, stackTrace) {
return _buildDefaultIcon();
},
), ),
); );
} else if (pictureUrl != null && pictureUrl!.isNotEmpty) { } else if (pictureUrl != null && pictureUrl!.isNotEmpty) {
@ -119,53 +250,61 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
width: radius * 2, width: radius * 2,
height: radius * 2, height: radius * 2,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const CircularProgressIndicator(color: Colors.white);
},
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
print('Error loading avatar image: $error');
return _buildDefaultIcon(); return _buildDefaultIcon();
}, },
), ),
); );
} else {
return _buildDefaultIcon();
} }
return _buildDefaultIcon();
} }
Widget _getDetailAvatarContent() { Widget _buildDefaultIcon({double? size, Color? color}) {
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() {
return Icon( return Icon(
BootstrapIcons.person, BootstrapIcons.person,
color: AppColors.whiteColor, color: color ?? AppColors.whiteColor,
size: radius * 1.2, 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 youtube_player_flutter: ^9.1.1
dev_dependencies: dev_dependencies:
flutter_launcher_icons: ^0.14.1
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.14.1
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true