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:
commit
8a2aa421e8
|
|
@ -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,
|
||||
userProvider.setLoading(false);
|
||||
|
||||
CustomSnackBar.show(
|
||||
context,
|
||||
message: 'Failed to update profile. Please try again.',
|
||||
isError: true);
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,64 +1,102 @@
|
|||
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,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Avatar with detail view
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
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,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
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: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: _getDetailAvatarContent(),
|
||||
Text(
|
||||
'Select Image Source',
|
||||
style: AppTextStyles.blackTextStyle.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryColor,
|
||||
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));
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
_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));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -66,29 +104,119 @@ class UserAvatar extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
);
|
||||
} 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: _getAvatarContent(),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user