683 lines
22 KiB
Dart
683 lines
22 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:enaklo_pos/presentation/setting/bloc/upload_file/upload_file_bloc.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
import '../constants/colors.dart';
|
|
import '../constants/variables.dart';
|
|
import 'spaces.dart';
|
|
|
|
class ImagePickerWidget extends StatefulWidget {
|
|
final String label;
|
|
final void Function(XFile? file) onChanged;
|
|
final void Function(String? uploadedUrl)? onUploaded;
|
|
final bool showLabel;
|
|
final String? initialImageUrl;
|
|
final bool autoUpload;
|
|
|
|
const ImagePickerWidget({
|
|
super.key,
|
|
required this.label,
|
|
required this.onChanged,
|
|
this.onUploaded,
|
|
this.showLabel = true,
|
|
this.initialImageUrl,
|
|
this.autoUpload = false,
|
|
});
|
|
|
|
@override
|
|
State<ImagePickerWidget> createState() => _ImagePickerWidgetState();
|
|
}
|
|
|
|
class _ImagePickerWidgetState extends State<ImagePickerWidget>
|
|
with TickerProviderStateMixin {
|
|
String? imagePath;
|
|
String? uploadedImageUrl;
|
|
bool hasInitialImage = false;
|
|
bool isHovering = false;
|
|
bool isUploading = false;
|
|
late AnimationController _scaleController;
|
|
late AnimationController _fadeController;
|
|
late AnimationController _uploadController;
|
|
late Animation<double> _scaleAnimation;
|
|
late Animation<double> _fadeAnimation;
|
|
late Animation<double> _uploadAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
hasInitialImage = widget.initialImageUrl != null;
|
|
|
|
_scaleController = AnimationController(
|
|
duration: const Duration(milliseconds: 200),
|
|
vsync: this,
|
|
);
|
|
_fadeController = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
_uploadController = AnimationController(
|
|
duration: const Duration(milliseconds: 1000),
|
|
vsync: this,
|
|
);
|
|
|
|
_scaleAnimation = Tween<double>(
|
|
begin: 1.0,
|
|
end: 0.95,
|
|
).animate(CurvedAnimation(
|
|
parent: _scaleController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_fadeAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: _fadeController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_uploadAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: _uploadController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_fadeController.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scaleController.dispose();
|
|
_fadeController.dispose();
|
|
_uploadController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickImage() async {
|
|
_scaleController.forward().then((_) {
|
|
_scaleController.reverse();
|
|
});
|
|
|
|
final pickedFile = await ImagePicker().pickImage(
|
|
source: ImageSource.gallery,
|
|
);
|
|
|
|
if (pickedFile != null) {
|
|
setState(() {
|
|
imagePath = pickedFile.path;
|
|
hasInitialImage = false;
|
|
uploadedImageUrl = null;
|
|
});
|
|
|
|
widget.onChanged(pickedFile);
|
|
|
|
// Auto upload if enabled
|
|
if (widget.autoUpload) {
|
|
_uploadImage(pickedFile.path);
|
|
}
|
|
} else {
|
|
debugPrint('No image selected.');
|
|
widget.onChanged(null);
|
|
}
|
|
}
|
|
|
|
void _uploadImage(String filePath) {
|
|
setState(() {
|
|
isUploading = true;
|
|
});
|
|
_uploadController.forward();
|
|
|
|
context.read<UploadFileBloc>().add(
|
|
UploadFileEvent.upload(filePath),
|
|
);
|
|
}
|
|
|
|
Widget _buildImageContainer() {
|
|
return Container(
|
|
width: 100.0,
|
|
height: 100.0,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20.0),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(20.0),
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: imagePath != null
|
|
? Image.file(
|
|
File(imagePath!),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: uploadedImageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: uploadedImageUrl!.contains('http')
|
|
? uploadedImageUrl!
|
|
: '${Variables.baseUrl}/$uploadedImageUrl',
|
|
placeholder: (context, url) => Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
AppColors.primary.withOpacity(0.1),
|
|
AppColors.primary.withOpacity(0.05),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
child: const Center(
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
errorWidget: (context, url, error) =>
|
|
_buildPlaceholder(),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: hasInitialImage && widget.initialImageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: widget.initialImageUrl!.contains('http')
|
|
? widget.initialImageUrl!
|
|
: '${Variables.baseUrl}/${widget.initialImageUrl}',
|
|
placeholder: (context, url) => Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
AppColors.primary.withOpacity(0.1),
|
|
AppColors.primary.withOpacity(0.05),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
child: const Center(
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
errorWidget: (context, url, error) =>
|
|
_buildPlaceholder(),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: _buildPlaceholder(),
|
|
),
|
|
// Upload progress overlay
|
|
if (isUploading)
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.6),
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2,
|
|
value: _uploadAnimation.value == 1.0
|
|
? null
|
|
: _uploadAnimation.value,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Uploading...',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Overlay gradient for better button visibility
|
|
if ((imagePath != null ||
|
|
uploadedImageUrl != null ||
|
|
(hasInitialImage && widget.initialImageUrl != null)) &&
|
|
!isUploading)
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withOpacity(0.3),
|
|
],
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaceholder() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
AppColors.primary.withOpacity(0.1),
|
|
AppColors.primary.withOpacity(0.05),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.add_photo_alternate_outlined,
|
|
size: 32,
|
|
color: AppColors.primary.withOpacity(0.6),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Photo',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: AppColors.primary.withOpacity(0.6),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButton() {
|
|
bool hasImage = imagePath != null ||
|
|
uploadedImageUrl != null ||
|
|
(hasInitialImage && widget.initialImageUrl != null);
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
AppColors.primary,
|
|
AppColors.primary.withOpacity(0.8),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.primary.withOpacity(0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: isUploading ? null : _pickImage,
|
|
onHover: (hover) {
|
|
setState(() {
|
|
isHovering = hover;
|
|
});
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: isHovering
|
|
? Colors.white.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isUploading
|
|
? Icons.cloud_upload_outlined
|
|
: hasImage
|
|
? Icons.edit_outlined
|
|
: Icons.add_photo_alternate_outlined,
|
|
color: Colors.white,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
isUploading
|
|
? 'Uploading...'
|
|
: hasImage
|
|
? 'Change Photo'
|
|
: 'Choose Photo',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUploadButton() {
|
|
if (!widget.autoUpload &&
|
|
imagePath != null &&
|
|
uploadedImageUrl == null &&
|
|
!isUploading) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.green.shade600,
|
|
Colors.green.shade500,
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.green.withOpacity(0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: () => _uploadImage(imagePath!),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.cloud_upload_outlined,
|
|
color: Colors.white,
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Upload to Server',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<UploadFileBloc, UploadFileState>(
|
|
listener: (context, state) {
|
|
state.when(
|
|
initial: () {},
|
|
loading: () {
|
|
if (!isUploading) {
|
|
setState(() {
|
|
isUploading = true;
|
|
});
|
|
_uploadController.repeat();
|
|
}
|
|
},
|
|
success: (fileData) {
|
|
setState(() {
|
|
isUploading = false;
|
|
uploadedImageUrl = fileData.fileUrl;
|
|
});
|
|
_uploadController.reset();
|
|
|
|
if (widget.onUploaded != null) {
|
|
widget.onUploaded!(fileData.fileUrl);
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.white),
|
|
SizedBox(width: 8),
|
|
Text('Image uploaded successfully!'),
|
|
],
|
|
),
|
|
backgroundColor: Colors.green,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
},
|
|
error: (message) {
|
|
setState(() {
|
|
isUploading = false;
|
|
});
|
|
_uploadController.reset();
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.white),
|
|
SizedBox(width: 8),
|
|
Expanded(child: Text('Upload failed: $message')),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
duration: Duration(seconds: 3),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
child: FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (widget.showLabel) ...[
|
|
Text(
|
|
widget.label,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SpaceHeight(16.0),
|
|
],
|
|
ScaleTransition(
|
|
scale: _scaleAnimation,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(20.0),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(24.0),
|
|
color: Colors.white,
|
|
border: Border.all(
|
|
color: isUploading
|
|
? Colors.orange.withOpacity(0.5)
|
|
: uploadedImageUrl != null
|
|
? Colors.green.withOpacity(0.5)
|
|
: AppColors.primary.withOpacity(0.2),
|
|
width: 1.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildImageContainer(),
|
|
const SizedBox(height: 20),
|
|
_buildActionButton(),
|
|
_buildUploadButton(),
|
|
if ((imagePath != null ||
|
|
uploadedImageUrl != null ||
|
|
(hasInitialImage &&
|
|
widget.initialImageUrl != null)) &&
|
|
!isUploading) ...[
|
|
const SizedBox(height: 12),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
imagePath = null;
|
|
hasInitialImage = false;
|
|
uploadedImageUrl = null;
|
|
});
|
|
widget.onChanged(null);
|
|
if (widget.onUploaded != null) {
|
|
widget.onUploaded!(null);
|
|
}
|
|
},
|
|
icon: Icon(
|
|
Icons.delete_outline,
|
|
size: 16,
|
|
color: Colors.red.shade400,
|
|
),
|
|
label: Text(
|
|
'Remove Photo',
|
|
style: TextStyle(
|
|
color: Colors.red.shade400,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Upload status indicator
|
|
if (uploadedImageUrl != null && !isUploading) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border:
|
|
Border.all(color: Colors.green.withOpacity(0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.cloud_done_outlined,
|
|
size: 14,
|
|
color: Colors.green.shade600,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Uploaded',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.green.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Cara menggunakan widget ini:
|
|
///
|
|
/// ```dart
|
|
/// // 1. Basic usage tanpa auto upload
|
|
/// ImagePickerWidget(
|
|
/// label: 'Product Image',
|
|
/// onChanged: (file) {
|
|
/// // Handle selected file
|
|
/// print('Selected file: ${file?.path}');
|
|
/// },
|
|
/// onUploaded: (url) {
|
|
/// // Handle uploaded URL
|
|
/// print('Uploaded URL: $url');
|
|
/// },
|
|
/// )
|
|
///
|
|
/// // 2. Auto upload setelah memilih gambar
|
|
/// ImagePickerWidget(
|
|
/// label: 'Profile Picture',
|
|
/// autoUpload: true,
|
|
/// onChanged: (file) => setState(() => selectedFile = file),
|
|
/// onUploaded: (url) => setState(() => profileImageUrl = url),
|
|
/// )
|
|
///
|
|
/// // 3. Dengan initial image
|
|
/// ImagePickerWidget(
|
|
/// label: 'Banner Image',
|
|
/// initialImageUrl: existingImageUrl,
|
|
/// onChanged: (file) => handleFileChange(file),
|
|
/// onUploaded: (url) => handleUploadSuccess(url),
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// Pastikan untuk wrap widget ini dengan BlocProvider:
|
|
/// ```dart
|
|
/// BlocProvider(
|
|
/// create: (context) => UploadFileBloc(
|
|
/// context.read<FileRemoteDataSource>(),
|
|
/// ),
|
|
/// child: ImagePickerWidget(...),
|
|
/// )
|
|
/// ``` |