1360 lines
49 KiB
Dart
1360 lines
49 KiB
Dart
import 'dart:ui';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import '../../../services/auth_service.dart';
|
|
import '../../../routes/app_routes.dart';
|
|
|
|
class ProfileUserScreen extends StatefulWidget {
|
|
const ProfileUserScreen({super.key});
|
|
|
|
@override
|
|
State<ProfileUserScreen> createState() => _ProfileUserScreenState();
|
|
}
|
|
|
|
class _ProfileUserScreenState extends State<ProfileUserScreen> {
|
|
bool isLoading = true;
|
|
bool isProcessing = false;
|
|
|
|
Map<String, dynamic> userData = {};
|
|
|
|
File? _imageFile;
|
|
final ImagePicker _picker = ImagePicker();
|
|
|
|
bool showOldPassword = false;
|
|
bool showNewPassword = false;
|
|
bool showConfirmPassword = false;
|
|
|
|
// ── Design tokens ──────────────────────────────────────────────
|
|
static const _blue = Color(0xFF3B82F6);
|
|
static const _bgPage = Color(0xFFF0F4FA);
|
|
static const _cardBg = Colors.white;
|
|
static const _textPrimary = Color(0xFF0F172A);
|
|
static const _textSecondary = Color(0xFF64748B);
|
|
static const _textHint = Color(0xFF94A3B8);
|
|
static const _border = Color(0xFFE2E8F0);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
loadProfile();
|
|
}
|
|
|
|
Future<void> loadProfile() async {
|
|
final result = await AuthService.getProfile();
|
|
if (!mounted) return;
|
|
if (result['success']) {
|
|
setState(() {
|
|
userData = result['data'];
|
|
isLoading = false;
|
|
});
|
|
} else {
|
|
setState(() => isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> pickImage() async {
|
|
try {
|
|
final XFile? pickedFile = await _picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
imageQuality: 85,
|
|
);
|
|
if (pickedFile == null) return;
|
|
|
|
File file = File(pickedFile.path);
|
|
setState(() => _imageFile = file);
|
|
await uploadPhoto(file);
|
|
} catch (e) {
|
|
_showSnack('Gagal memilih foto: $e', Colors.red);
|
|
}
|
|
}
|
|
|
|
Future<void> uploadPhoto(File image) async {
|
|
setState(() => isProcessing = true);
|
|
try {
|
|
final result = await AuthService.uploadPhoto(image);
|
|
if (!mounted) return;
|
|
|
|
if (result['status'] == true) {
|
|
setState(() {
|
|
userData['foto'] = result['data']['foto'];
|
|
_imageFile = null;
|
|
});
|
|
_showSnack("Foto profil berhasil diperbarui", Colors.green);
|
|
} else {
|
|
_showSnack("Upload gagal: ${result['message'] ?? 'Coba lagi'}", Colors.red);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showSnack("Error: $e", Colors.red);
|
|
} finally {
|
|
if (mounted) setState(() => isProcessing = false);
|
|
}
|
|
}
|
|
|
|
Future<void> deletePhoto() async {
|
|
setState(() => isProcessing = true);
|
|
try {
|
|
final result = await AuthService.deletePhoto();
|
|
if (!mounted) return;
|
|
|
|
if (result['status'] == true) {
|
|
setState(() {
|
|
userData['foto'] = null;
|
|
_imageFile = null;
|
|
});
|
|
_showSnack("Foto profil berhasil dihapus", Colors.green);
|
|
} else {
|
|
_showSnack("Gagal hapus foto: ${result['message'] ?? 'Coba lagi'}", Colors.red);
|
|
}
|
|
} catch (e) {
|
|
_showSnack("Error: $e", Colors.red);
|
|
} finally {
|
|
if (mounted) setState(() => isProcessing = false);
|
|
}
|
|
}
|
|
|
|
void _showSnack(String msg, Color color) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(msg), backgroundColor: color),
|
|
);
|
|
}
|
|
|
|
Widget buildLoading() {
|
|
if (!isProcessing) return const SizedBox();
|
|
return Positioned.fill(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
|
|
child: Container(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: Colors.black.withValues(alpha: 0.25),
|
|
child: const Center(
|
|
child: CircularProgressIndicator(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isLoading) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: _bgPage,
|
|
body: Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
child: Column(children: [_buildHeader(), _buildCard()]),
|
|
),
|
|
buildLoading(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
width: double.infinity,
|
|
height: 300,
|
|
decoration: const BoxDecoration(
|
|
image: DecorationImage(
|
|
image: AssetImage('assets/images/gedunga3.jpg'),
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
child: Container(
|
|
// FIX: replaced withOpacity with withValues
|
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)),
|
|
child: SafeArea(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
GestureDetector(
|
|
onTap: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const Text("Foto Profil",
|
|
style: TextStyle(
|
|
fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
leading:
|
|
const Icon(Icons.image_rounded, color: _blue),
|
|
title: const Text("Ganti Foto"),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
pickImage();
|
|
},
|
|
),
|
|
if (userData['foto'] != null && userData['foto'] != '')
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_rounded,
|
|
color: Colors.red),
|
|
title: const Text("Hapus Foto",
|
|
style: TextStyle(color: Colors.red)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_confirmDeletePhoto();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
Container(
|
|
width: 98,
|
|
height: 98,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: [_blue, Color(0xFF60A5FA), Color(0xFF93C5FD)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 3,
|
|
left: 3,
|
|
right: 3,
|
|
bottom: 3,
|
|
child: CircleAvatar(
|
|
backgroundColor: const Color(0xFF1E3A5F),
|
|
backgroundImage: _imageFile != null
|
|
? FileImage(_imageFile!)
|
|
: (userData['foto'] != null && userData['foto'] != '')
|
|
? NetworkImage(userData['foto'])
|
|
: null,
|
|
child: (_imageFile == null &&
|
|
(userData['foto'] == null ||
|
|
userData['foto'] == ''))
|
|
? const Icon(Icons.person_rounded,
|
|
size: 44, color: _blue)
|
|
: null,
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 2,
|
|
right: 2,
|
|
child: Container(
|
|
width: 26,
|
|
height: 26,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _blue,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
),
|
|
child: const Icon(Icons.edit_rounded,
|
|
size: 12, color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
userData['name'] ?? '-',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
userData['email'] ?? '-',
|
|
// FIX: replaced withOpacity with withValues
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.6), fontSize: 13),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: _blue.withValues(alpha: 0.25),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: _blue.withValues(alpha: 0.55), width: 0.5),
|
|
),
|
|
child: const Text(
|
|
'PETUGAS',
|
|
style: TextStyle(
|
|
color: Color(0xFF93C5FD),
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 1.2,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCard() {
|
|
return Transform.translate(
|
|
offset: const Offset(0, -28),
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
color: _bgPage,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
margin: const EdgeInsets.only(top: 10, bottom: 20),
|
|
width: 38,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: Colors.black.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Row(
|
|
children: [
|
|
_buildInfoTile(
|
|
'Nama', userData['name'] ?? '-',
|
|
const Color(0xFFEFF6FF), _blue),
|
|
const SizedBox(width: 10),
|
|
_buildInfoTile(
|
|
'No HP', userData['no_hp'] ?? '-',
|
|
const Color(0xFFF0FDFA), const Color(0xFF0D9488)),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 22),
|
|
child: Text(
|
|
'PENGATURAN AKUN',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: _textHint,
|
|
letterSpacing: 1.3,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
_buildMenuItem(
|
|
icon: Icons.person_outline_rounded,
|
|
iconBg: const Color(0xFFEFF6FF),
|
|
iconColor: _blue,
|
|
title: 'Edit Profil',
|
|
subtitle: userData['name'] ?? '-',
|
|
onTap: editProfile,
|
|
),
|
|
_buildMenuItem(
|
|
icon: Icons.email_outlined,
|
|
iconBg: const Color(0xFFF0FDFA),
|
|
iconColor: const Color(0xFF0D9488),
|
|
title: 'Ubah Email',
|
|
subtitle: userData['email'] ?? '-',
|
|
onTap: changeEmail,
|
|
),
|
|
_buildMenuItem(
|
|
icon: Icons.lock_outline_rounded,
|
|
iconBg: const Color(0xFFFFF7ED),
|
|
iconColor: const Color(0xFFF97316),
|
|
title: 'Ubah Password',
|
|
subtitle: 'Diperbarui baru-baru ini',
|
|
onTap: changePassword,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
child: Divider(color: _border, thickness: 0.5),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: _buildMenuItem(
|
|
icon: Icons.logout_rounded,
|
|
iconBg: const Color(0xFFFEE2E2),
|
|
iconColor: const Color(0xFFEF4444),
|
|
title: 'Keluar Akun',
|
|
subtitle: 'Sesi akan dihapus',
|
|
onTap: confirmLogout,
|
|
isLogout: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoTile(
|
|
String label, String value, Color bg, Color valueColor) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: _cardBg,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: _border, width: 0.5),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: _textHint,
|
|
letterSpacing: 0.8,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: valueColor),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMenuItem({
|
|
required IconData icon,
|
|
required Color iconBg,
|
|
required Color iconColor,
|
|
required String title,
|
|
required String subtitle,
|
|
required VoidCallback onTap,
|
|
bool isLogout = false,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: isLogout ? const Color(0xFFFFF5F5) : _cardBg,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isLogout
|
|
// FIX: replaced withOpacity with withValues
|
|
? const Color(0xFFEF4444).withValues(alpha: 0.15)
|
|
: _border,
|
|
width: 0.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
// FIX: replaced withOpacity with withValues
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: iconBg, borderRadius: BorderRadius.circular(13)),
|
|
child: Icon(icon, color: iconColor, size: 18),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: isLogout
|
|
? const Color(0xFFEF4444)
|
|
: _textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isLogout
|
|
? const Color(0xFFFCA5A5)
|
|
: _textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: isLogout
|
|
? const Color(0xFFFEE2E2)
|
|
: const Color(0xFFF1F5F9),
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
child: Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 16,
|
|
color: isLogout ? const Color(0xFFEF4444) : _textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// SHARED WIDGETS
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
Widget _styledField({
|
|
required TextEditingController controller,
|
|
required String label,
|
|
required IconData prefixIcon,
|
|
Color prefixColor = _blue,
|
|
bool obscure = false,
|
|
bool showToggle = false,
|
|
bool toggleValue = false,
|
|
VoidCallback? onToggle,
|
|
String? hint,
|
|
TextInputType? keyboardType,
|
|
int? maxLength,
|
|
ValueChanged<String>? onChanged,
|
|
}) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: _textSecondary,
|
|
letterSpacing: 0.3,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
TextField(
|
|
controller: controller,
|
|
obscureText: obscure && !toggleValue,
|
|
keyboardType: keyboardType,
|
|
maxLength: maxLength,
|
|
onChanged: onChanged,
|
|
style: const TextStyle(fontSize: 13, color: _textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
hintStyle: const TextStyle(color: _textHint, fontSize: 13),
|
|
prefixIcon: Icon(prefixIcon, color: prefixColor, size: 18),
|
|
counterText: '',
|
|
suffixIcon: showToggle
|
|
? IconButton(
|
|
icon: Icon(
|
|
toggleValue
|
|
? Icons.visibility_rounded
|
|
: Icons.visibility_off_rounded,
|
|
color: _textHint,
|
|
size: 18,
|
|
),
|
|
onPressed: onToggle,
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: const Color(0xFFFAFBFC),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _border, width: 1.5),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _border, width: 1.5),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _blue, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _dialogShell({
|
|
required Color iconBg,
|
|
required Color iconColor,
|
|
required IconData icon,
|
|
required String title,
|
|
required String subtitle,
|
|
required List<Widget> fields,
|
|
required String saveLabel,
|
|
required VoidCallback onSave,
|
|
Color? saveBtnColor,
|
|
VoidCallback? onCancel,
|
|
}) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
elevation: 0,
|
|
backgroundColor: Colors.white,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: iconBg,
|
|
borderRadius: BorderRadius.circular(12)),
|
|
child: Icon(icon, color: iconColor, size: 18),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: _textPrimary)),
|
|
Text(subtitle,
|
|
style: const TextStyle(
|
|
fontSize: 11.5, color: _textHint)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
...fields,
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: onCancel ?? () => Navigator.pop(context),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
side: const BorderSide(color: _border, width: 1.5),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: const Text('Batal',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: _textSecondary)),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: ElevatedButton(
|
|
onPressed: onSave,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: saveBtnColor ?? _blue,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: Text(saveLabel,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// DIALOGS
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
void _confirmDeletePhoto() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => Dialog(
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 60,
|
|
height: 60,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFFEE2E2), shape: BoxShape.circle),
|
|
child: const Icon(Icons.delete_forever_rounded,
|
|
color: Colors.red, size: 30),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text("Hapus Foto?",
|
|
style:
|
|
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
"Foto profil kamu akan dihapus secara permanen.",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text("Batal"),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
deletePhoto();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red),
|
|
child: const Text("Hapus"),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void editProfile() {
|
|
final nameController =
|
|
TextEditingController(text: userData['name']);
|
|
final phoneController =
|
|
TextEditingController(text: userData['no_hp'] ?? '');
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => _dialogShell(
|
|
icon: Icons.person_outline_rounded,
|
|
iconBg: const Color(0xFFEFF6FF),
|
|
iconColor: _blue,
|
|
title: 'Edit Profil',
|
|
subtitle: 'Perbarui nama dan nomor HP',
|
|
saveLabel: 'Simpan',
|
|
fields: [
|
|
_styledField(
|
|
controller: nameController,
|
|
label: 'Nama Lengkap',
|
|
prefixIcon: Icons.person_outline_rounded,
|
|
),
|
|
const SizedBox(height: 14),
|
|
_styledField(
|
|
controller: phoneController,
|
|
label: 'No HP',
|
|
prefixIcon: Icons.phone_outlined,
|
|
prefixColor: const Color(0xFF0D9488),
|
|
hint: 'Nomor HP kamu...',
|
|
),
|
|
],
|
|
onSave: () async {
|
|
setState(() => isProcessing = true);
|
|
final result = await AuthService.updateProfile(
|
|
name: nameController.text,
|
|
email: userData['email'],
|
|
noHp: phoneController.text.isEmpty
|
|
? null
|
|
: phoneController.text,
|
|
);
|
|
setState(() => isProcessing = false);
|
|
if (result['success']) {
|
|
setState(() {
|
|
userData['name'] = nameController.text;
|
|
userData['no_hp'] = phoneController.text;
|
|
});
|
|
if (!mounted) return;
|
|
Navigator.pop(context);
|
|
_showSnack('Profil berhasil diperbarui', Colors.green);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// UBAH EMAIL — dengan alur OTP
|
|
// ══════════════════════════════════════════════════════════════════
|
|
void changeEmail() {
|
|
final emailController = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => _dialogShell(
|
|
icon: Icons.email_outlined,
|
|
iconBg: const Color(0xFFF0FDFA),
|
|
iconColor: const Color(0xFF0D9488),
|
|
title: 'Ubah Email',
|
|
subtitle: 'Kode OTP akan dikirim ke email baru',
|
|
saveLabel: 'Kirim OTP',
|
|
fields: [
|
|
// Badge email aktif
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF0FDF4),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border:
|
|
Border.all(color: const Color(0xFF86EFAC), width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.check_circle_rounded,
|
|
size: 14, color: Color(0xFF16A34A)),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'Email aktif: ${userData['email'] ?? '-'}',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF166534),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
_styledField(
|
|
controller: emailController,
|
|
label: 'Email Baru',
|
|
prefixIcon: Icons.email_outlined,
|
|
prefixColor: const Color(0xFF0D9488),
|
|
hint: 'Masukkan email baru...',
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
const SizedBox(height: 6),
|
|
const Row(
|
|
children: [
|
|
Icon(Icons.send_rounded, size: 12, color: _textHint),
|
|
SizedBox(width: 4),
|
|
Text('Kode OTP 6 digit dikirim ke email baru',
|
|
style: TextStyle(fontSize: 10.5, color: _textHint)),
|
|
],
|
|
),
|
|
],
|
|
onSave: () async {
|
|
final newEmail = emailController.text.trim();
|
|
if (newEmail.isEmpty) {
|
|
_showSnack('Email tidak boleh kosong', Colors.red);
|
|
return;
|
|
}
|
|
|
|
setState(() => isProcessing = true);
|
|
|
|
final result = await AuthService.sendEmailOtp(newEmail);
|
|
|
|
setState(() => isProcessing = false);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result['success'] == true) {
|
|
Navigator.pop(context);
|
|
_showOtpDialog(newEmail);
|
|
} else {
|
|
_showSnack(
|
|
result['message'] ?? 'Gagal mengirim OTP, coba lagi',
|
|
Colors.red,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Dialog OTP ──────────────────────────────────────────────────
|
|
void _showOtpDialog(String newEmail) {
|
|
final List<TextEditingController> otpControllers =
|
|
List.generate(6, (_) => TextEditingController());
|
|
final List<FocusNode> focusNodes =
|
|
List.generate(6, (_) => FocusNode());
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) {
|
|
// isSending must live outside StatefulBuilder.builder so that
|
|
// setStateDialog(() => isSending = ...) actually triggers a rebuild.
|
|
bool isSending = false;
|
|
|
|
return StatefulBuilder(
|
|
builder: (context, setStateDialog) {
|
|
String getOtpCode() =>
|
|
otpControllers.map((c) => c.text).join();
|
|
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24)),
|
|
backgroundColor: Colors.white,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF0FDFA),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Icon(Icons.mark_email_read_outlined,
|
|
color: Color(0xFF0D9488), size: 26),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
const Text(
|
|
'Verifikasi Email',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
color: _textPrimary),
|
|
),
|
|
const SizedBox(height: 6),
|
|
|
|
RichText(
|
|
textAlign: TextAlign.center,
|
|
text: TextSpan(
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
color: _textHint,
|
|
height: 1.5),
|
|
children: [
|
|
const TextSpan(text: 'Kode OTP telah dikirim ke\n'),
|
|
TextSpan(
|
|
text: newEmail,
|
|
style: const TextStyle(
|
|
color: Color(0xFF0D9488),
|
|
fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// 6 kotak OTP
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: List.generate(6, (i) {
|
|
return SizedBox(
|
|
width: 42,
|
|
height: 50,
|
|
child: TextField(
|
|
controller: otpControllers[i],
|
|
focusNode: focusNodes[i],
|
|
textAlign: TextAlign.center,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 1,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
color: _textPrimary),
|
|
decoration: InputDecoration(
|
|
counterText: '',
|
|
filled: true,
|
|
fillColor: const Color(0xFFF8FAFC),
|
|
contentPadding: EdgeInsets.zero,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(
|
|
color: _border, width: 1.5),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(
|
|
color: _border, width: 1.5),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: const BorderSide(
|
|
color: _blue, width: 2),
|
|
),
|
|
),
|
|
onChanged: (val) {
|
|
if (val.isNotEmpty && i < 5) {
|
|
FocusScope.of(context)
|
|
.requestFocus(focusNodes[i + 1]);
|
|
} else if (val.isEmpty && i > 0) {
|
|
FocusScope.of(context)
|
|
.requestFocus(focusNodes[i - 1]);
|
|
}
|
|
setStateDialog(() {});
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// Tombol Verifikasi
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: isSending
|
|
? null
|
|
: () async {
|
|
final otp = getOtpCode();
|
|
if (otp.length < 6) {
|
|
_showSnack(
|
|
'Masukkan 6 digit kode OTP',
|
|
Colors.orange);
|
|
return;
|
|
}
|
|
|
|
setStateDialog(() => isSending = true);
|
|
|
|
final result =
|
|
await AuthService.verifyEmailOtp(
|
|
newEmail,
|
|
otp,
|
|
);
|
|
|
|
setStateDialog(() => isSending = false);
|
|
|
|
// FIX: use_build_context_synchronously —
|
|
// check mounted on the State before using
|
|
// this.context after the await gap.
|
|
if (!mounted) return;
|
|
|
|
if (result['success'] == true) {
|
|
setState(
|
|
() => userData['email'] = newEmail);
|
|
// ignore: use_build_context_synchronously
|
|
Navigator.pop(context);
|
|
_showSnack('Email berhasil diperbarui',
|
|
Colors.green);
|
|
} else {
|
|
_showSnack(
|
|
result['message'] ??
|
|
'Kode OTP salah atau sudah kadaluarsa',
|
|
Colors.red,
|
|
);
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF0D9488),
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 13),
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: isSending
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white),
|
|
)
|
|
: const Text(
|
|
'Verifikasi',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
// Kirim Ulang + Batal
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
final result =
|
|
await AuthService.sendEmailOtp(newEmail);
|
|
if (!mounted) return;
|
|
if (result['success'] == true) {
|
|
_showSnack(
|
|
'OTP baru telah dikirim', Colors.blue);
|
|
} else {
|
|
_showSnack(
|
|
'Gagal mengirim ulang OTP', Colors.red);
|
|
}
|
|
},
|
|
child: const Text(
|
|
'Kirim Ulang OTP',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: _blue,
|
|
fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Batal',
|
|
style: TextStyle(
|
|
fontSize: 12, color: _textHint)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void changePassword() {
|
|
final oldPass = TextEditingController();
|
|
final newPass = TextEditingController();
|
|
final confirmPass = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setStateDialog) => _dialogShell(
|
|
icon: Icons.lock_outline_rounded,
|
|
iconBg: const Color(0xFFFFF7ED),
|
|
iconColor: const Color(0xFFF97316),
|
|
title: 'Ubah Password',
|
|
subtitle: 'Gunakan password yang kuat',
|
|
saveLabel: 'Perbarui',
|
|
saveBtnColor: const Color(0xFFEF4444),
|
|
fields: [
|
|
_styledField(
|
|
controller: oldPass,
|
|
label: 'Password Lama',
|
|
prefixIcon: Icons.lock_outline_rounded,
|
|
prefixColor: const Color(0xFFF97316),
|
|
obscure: true,
|
|
showToggle: true,
|
|
toggleValue: showOldPassword,
|
|
onToggle: () =>
|
|
setStateDialog(() => showOldPassword = !showOldPassword),
|
|
),
|
|
const SizedBox(height: 14),
|
|
_styledField(
|
|
controller: newPass,
|
|
label: 'Password Baru',
|
|
prefixIcon: Icons.lock_outline_rounded,
|
|
prefixColor: _blue,
|
|
obscure: true,
|
|
showToggle: true,
|
|
toggleValue: showNewPassword,
|
|
onToggle: () => setStateDialog(
|
|
() => showNewPassword = !showNewPassword),
|
|
),
|
|
const SizedBox(height: 14),
|
|
_styledField(
|
|
controller: confirmPass,
|
|
label: 'Konfirmasi Password',
|
|
prefixIcon: Icons.check_circle_outline_rounded,
|
|
prefixColor: const Color(0xFF22C55E),
|
|
obscure: true,
|
|
showToggle: true,
|
|
toggleValue: showConfirmPassword,
|
|
onToggle: () => setStateDialog(
|
|
() => showConfirmPassword = !showConfirmPassword),
|
|
),
|
|
],
|
|
onSave: () async {
|
|
if (newPass.text != confirmPass.text) {
|
|
_showSnack('Password tidak sama', Colors.red);
|
|
return;
|
|
}
|
|
// Capture navigator before the async gap to avoid
|
|
// use_build_context_synchronously warning.
|
|
final nav = Navigator.of(context);
|
|
setState(() => isProcessing = true);
|
|
final result = await AuthService.updatePassword(
|
|
oldPass.text, newPass.text);
|
|
setState(() => isProcessing = false);
|
|
if (result['success']) {
|
|
if (!mounted) return;
|
|
nav.pop();
|
|
_showSnack('Password berhasil diperbarui', Colors.green);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void confirmLogout() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24)),
|
|
backgroundColor: Colors.white,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFFEE2E2), shape: BoxShape.circle),
|
|
child: const Icon(Icons.logout_rounded,
|
|
color: Color(0xFFEF4444), size: 26),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text('Keluar Akun?',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
color: _textPrimary)),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
'Sesi kamu akan dihapus dan kamu\nharus login kembali.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12.5, color: _textHint, height: 1.5),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
style: OutlinedButton.styleFrom(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 12),
|
|
side: const BorderSide(
|
|
color: _border, width: 1.5),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: const Text('Batal',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: _textSecondary)),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: ElevatedButton(
|
|
onPressed: () async {
|
|
await AuthService.logout();
|
|
if (!mounted) return;
|
|
Navigator.pushNamedAndRemoveUntil(
|
|
context,
|
|
AppRoutes.login,
|
|
(route) => false,
|
|
);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFFEF4444),
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 12),
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: const Text('Keluar',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |