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/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);
|
||||||
|
|
||||||
|
CustomSnackBar.show(
|
||||||
|
context,
|
||||||
message: 'Failed to update profile. Please try again.',
|
message: 'Failed to update profile. Please try again.',
|
||||||
isError: true);
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,102 @@
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
String _formatFileSize(int bytes) {
|
||||||
Widget build(BuildContext context) {
|
if (bytes <= 0) return '0 B';
|
||||||
return Stack(
|
const suffixes = ['B', 'KB', 'MB', 'GB'];
|
||||||
children: [
|
var i = (log(bytes) / log(1024)).floor();
|
||||||
// Avatar with detail view
|
return '${(bytes / pow(1024, i)).toStringAsFixed(1)} ${suffixes[i]}';
|
||||||
GestureDetector(
|
}
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
Future<void> _handleImageSelection(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
|
||||||
|
// Show bottom sheet for image source selection
|
||||||
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
backgroundColor: Colors.white,
|
||||||
return Dialog(
|
shape: const RoundedRectangleBorder(
|
||||||
child: Container(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
width: double.infinity,
|
|
||||||
height: 400,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: ClipRRect(
|
'Select Image Source',
|
||||||
borderRadius: const BorderRadius.vertical(
|
style: AppTextStyles.blackTextStyle.copyWith(
|
||||||
top: Radius.circular(12),
|
fontSize: 16,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: _getDetailAvatarContent(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
const SizedBox(height: 20),
|
||||||
padding: const EdgeInsets.all(16.0),
|
Row(
|
||||||
child: TextButton(
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
style: TextButton.styleFrom(
|
children: [
|
||||||
foregroundColor: AppColors.primaryColor,
|
_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),
|
_ImageSourceOption(
|
||||||
child: const Text('Close'),
|
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(
|
child: CircleAvatar(
|
||||||
radius: radius,
|
radius: radius,
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: AppColors.primaryColor,
|
||||||
child: _getAvatarContent(),
|
child: isLoading
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: _getAvatarContent(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Camera icon
|
),
|
||||||
if (showCameraIcon)
|
if (showCameraIcon && !isLoading)
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user