import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; class ProfilePage extends ConsumerStatefulWidget { const ProfilePage({super.key}); @override ConsumerState createState() => _ProfilePageState(); } class _ProfilePageState extends ConsumerState { static const _ink = Color(0xFF1A1F2C); static const _surface = Colors.white; static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); UserProfile? _cachedProfile; String? _editingField; TextEditingController? _nameController; TextEditingController? _phoneController; TextEditingController? _departmentController; TextEditingController? _codeController; final FocusNode _nameFocus = FocusNode(); final FocusNode _departmentFocus = FocusNode(); final FocusNode _phoneFocus = FocusNode(); final FocusNode _phoneCodeFocus = FocusNode(); bool _nameTouched = false; bool _departmentTouched = false; bool _phoneTouched = false; bool _phoneCodeTouched = false; bool _isSavingField = false; String _initialPhone = ''; bool _isPhoneChanged = false; bool _isPhoneVerified = false; bool _isCodeSent = false; bool _isVerifying = false; @override void initState() { super.initState(); _nameFocus.addListener(_onNameFocusChange); _departmentFocus.addListener(_onDepartmentFocusChange); _phoneFocus.addListener(_onPhoneFocusChange); _phoneCodeFocus.addListener(_onPhoneCodeFocusChange); } void _onNameFocusChange() { if (!mounted) return; if (!_nameFocus.hasFocus && _nameTouched) { final profile = ref.read(profileProvider).value ?? _cachedProfile; if (profile != null) _autoSaveIfEditing(profile, 'name'); } else if (_nameFocus.hasFocus) { _nameTouched = true; } } void _onDepartmentFocusChange() { if (!mounted) return; if (!_departmentFocus.hasFocus && _departmentTouched) { final profile = ref.read(profileProvider).value ?? _cachedProfile; if (profile != null) _autoSaveIfEditing(profile, 'department'); } else if (_departmentFocus.hasFocus) { _departmentTouched = true; } } void _onPhoneFocusChange() { if (!mounted) return; if (!_phoneFocus.hasFocus && _phoneTouched) { final profile = ref.read(profileProvider).value ?? _cachedProfile; if (profile != null) _handlePhoneFocusChange(profile); } else if (_phoneFocus.hasFocus) { _phoneTouched = true; } } void _onPhoneCodeFocusChange() { if (!mounted) return; if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) { final profile = ref.read(profileProvider).value ?? _cachedProfile; if (profile != null) _handlePhoneFocusChange(profile); } else if (_phoneCodeFocus.hasFocus) { _phoneCodeTouched = true; } } @override void dispose() { _nameController?.dispose(); _phoneController?.dispose(); _departmentController?.dispose(); _codeController?.dispose(); _nameFocus.dispose(); _departmentFocus.dispose(); _phoneFocus.dispose(); _phoneCodeFocus.dispose(); super.dispose(); } Future _logout() async { AuthTokenStore.clear(); AuthNotifier.instance.notify(); } void _ensureControllers(UserProfile profile) { _nameController ??= TextEditingController(text: profile.name); _departmentController ??= TextEditingController(text: profile.department); _codeController ??= TextEditingController(); if (_phoneController == null) { _phoneController = TextEditingController(text: profile.phone); _initialPhone = profile.phone; _phoneController!.addListener(_onPhoneChanged); } if (_editingField != 'name' && _nameController!.text != profile.name) { _nameController!.text = profile.name; } if (_editingField != 'department' && _departmentController!.text != profile.department) { _departmentController!.text = profile.department; } if (_editingField != 'phone' && _phoneController!.text != profile.phone) { _phoneController!.text = profile.phone; _initialPhone = profile.phone; _resetPhoneState(); } } void _onPhoneChanged() { if (_phoneController == null) return; final changed = _phoneController!.text != _initialPhone; if (changed != _isPhoneChanged) { setState(() { _isPhoneChanged = changed; if (_isPhoneChanged) { _isPhoneVerified = false; _isCodeSent = false; _codeController?.clear(); } }); } } void _resetPhoneState() { _isPhoneChanged = false; _isPhoneVerified = false; _isCodeSent = false; _isVerifying = false; _codeController?.clear(); _phoneTouched = false; _phoneCodeTouched = false; } void _startEditing(String field, UserProfile profile) { setState(() { _editingField = field; if (field == 'name') { _nameController?.text = profile.name; } else if (field == 'department') { _departmentController?.text = profile.department; } else if (field == 'phone') { _phoneController?.text = profile.phone; _initialPhone = profile.phone; _resetPhoneState(); } }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (field == 'name') { FocusScope.of(context).requestFocus(_nameFocus); } else if (field == 'department') { FocusScope.of(context).requestFocus(_departmentFocus); } else if (field == 'phone') { FocusScope.of(context).requestFocus(_phoneFocus); } }); } void _cancelEditing(UserProfile profile) { setState(() { if (_editingField == 'name') { _nameController?.text = profile.name; } else if (_editingField == 'department') { _departmentController?.text = profile.department; } else if (_editingField == 'phone') { _phoneController?.text = profile.phone; _initialPhone = profile.phone; _resetPhoneState(); } _editingField = null; _nameTouched = false; _departmentTouched = false; }); } Future _sendCode() async { final phone = _phoneController?.text ?? ''; if (phone.isEmpty) return; setState(() => _isVerifying = true); try { await ref.read(profileRepositoryProvider).sendUpdateCode(phone); setState(() { _isCodeSent = true; _isVerifying = false; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('인증번호가 전송되었습니다.')), ); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('전송 실패: $e')), ); } } } Future _verifyCode(UserProfile profile) async { final phone = _phoneController?.text ?? ''; final code = _codeController?.text ?? ''; if (code.isEmpty) return; setState(() => _isVerifying = true); try { await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code); setState(() { _isPhoneVerified = true; _isVerifying = false; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('인증되었습니다.')), ); } if (_editingField == 'phone') { await _saveField(profile); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('인증 실패: $e')), ); } } } void _autoSaveIfEditing(UserProfile profile, String field) { if (_editingField != field) return; if (_isVerifying) return; if (_isSavingField) return; if (!_hasFieldChanged(profile, field)) { setState(() { if (field == 'phone') { _resetPhoneState(); } _editingField = null; if (field == 'name') { _nameTouched = false; } else if (field == 'department') { _departmentTouched = false; } }); return; } _saveField(profile); } void _handlePhoneFocusChange(UserProfile profile) { if (_editingField != 'phone') return; if (_isVerifying) return; if (_isSavingField) return; if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return; if (!_hasFieldChanged(profile, 'phone')) { setState(() { _resetPhoneState(); _editingField = null; }); return; } _saveField(profile); } bool _hasFieldChanged(UserProfile profile, String field) { if (field == 'name') { return (_nameController?.text.trim() ?? '') != profile.name; } if (field == 'department') { return (_departmentController?.text.trim() ?? '') != profile.department; } if (field == 'phone') { return (_phoneController?.text.trim() ?? '') != profile.phone; } return false; } Future _saveField(UserProfile profile) async { if (_editingField == null) return; if (_isSavingField) return; final nextName = _editingField == 'name' ? _nameController!.text.trim() : profile.name; final nextPhone = _editingField == 'phone' ? _phoneController!.text.trim() : profile.phone; final nextDepartment = _editingField == 'department' ? _departmentController!.text.trim() : profile.department; if (_editingField == 'name' && nextName.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('이름을 입력해주세요.')), ); return; } if (_editingField == 'department' && nextDepartment.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('소속을 입력해주세요.')), ); return; } if (_editingField == 'phone') { if (nextPhone.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('휴대폰 번호를 입력해주세요.')), ); return; } if (_isPhoneChanged && !_isPhoneVerified) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), ); return; } } if (!_hasFieldChanged(profile, _editingField!)) { setState(() { if (_editingField == 'phone') { _resetPhoneState(); } _editingField = null; _nameTouched = false; _departmentTouched = false; }); return; } _isSavingField = true; try { await ref.read(profileProvider.notifier).updateProfile( name: nextName, phone: nextPhone, department: nextDepartment, ); if (mounted) { setState(() { if (_editingField == 'phone') { _initialPhone = nextPhone; _resetPhoneState(); } _editingField = null; _nameTouched = false; _departmentTouched = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('정보가 수정되었습니다.')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('수정 실패: $e')), ); } } finally { _isSavingField = false; } } Widget _buildSideMenu(BuildContext context) { return ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ ListTile( leading: const Icon(Icons.home_outlined), title: const Text('대시보드'), onTap: () => context.go('/'), ), ListTile( leading: const Icon(Icons.person_outline), title: const Text('내 정보'), selected: true, onTap: () => context.go('/profile'), ), ListTile( leading: const Icon(Icons.qr_code_scanner), title: const Text('QR 스캔'), onTap: () => context.go('/scan'), ), const Divider(), ListTile( leading: const Icon(Icons.logout), title: const Text('로그아웃'), onTap: _logout, ), ], ); } Widget _buildSectionTitle(String title, String subtitle) { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink), ), const SizedBox(width: 12), Text( subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600]), ), ], ); } Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(999), border: Border.all(color: _border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: _ink), const SizedBox(width: 6), Text( label, style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600), ), ], ), ); } Widget _buildHeaderCard(UserProfile profile) { final name = profile.name.isEmpty ? '이름 없음' : profile.name; final email = profile.email.isEmpty ? '이메일 없음' : profile.email; final department = profile.department.isEmpty ? '소속 정보 없음' : profile.department; return Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: _border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Row( children: [ const CircleAvatar( radius: 32, child: Icon(Icons.person, size: 32), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '안녕하세요, $name님', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: _ink), ), const SizedBox(height: 6), Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ _buildInfoChip(Icons.badge_outlined, '프로필 관리'), _buildInfoChip(Icons.apartment, department), ], ), ], ), ), ], ), ); } Widget _buildCard(Widget child) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: _border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.03), blurRadius: 12, offset: const Offset(0, 6), ), ], ), child: child, ); } Widget _buildReadOnlyTile(String label, String value) { final displayValue = value.isEmpty ? '-' : value; return ListTile( contentPadding: EdgeInsets.zero, title: Text(label), subtitle: Text(displayValue), trailing: Text( '읽기 전용', style: TextStyle(color: Colors.grey[500], fontSize: 12), ), ); } Widget _buildEditableTile({ required String field, required String label, required String value, required UserProfile profile, required bool isUpdating, required TextEditingController controller, }) { final isEditing = _editingField == field; final displayValue = value.isEmpty ? '-' : value; if (!isEditing) { return ListTile( contentPadding: EdgeInsets.zero, title: Text(label), subtitle: Text(displayValue), trailing: TextButton( onPressed: isUpdating ? null : () => _startEditing(field, profile), child: const Text('수정'), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: controller, focusNode: field == 'name' ? _nameFocus : _departmentFocus, textInputAction: TextInputAction.done, onSubmitted: (_) => _autoSaveIfEditing(profile, field), decoration: InputDecoration( border: const OutlineInputBorder(), hintText: label, ), ), ), const SizedBox(width: 12), OutlinedButton( onPressed: isUpdating ? null : () => _cancelEditing(profile), child: const Text('취소'), ), ], ), ], ); } Widget _buildPhoneEditor(UserProfile profile, bool isUpdating) { final isEditing = _editingField == 'phone'; final displayValue = profile.phone.isEmpty ? '-' : profile.phone; if (!isEditing) { return ListTile( contentPadding: EdgeInsets.zero, title: const Text('전화번호'), subtitle: Text(displayValue), trailing: TextButton( onPressed: isUpdating ? null : () => _startEditing('phone', profile), child: const Text('수정'), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('전화번호', style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _phoneController, focusNode: _phoneFocus, keyboardType: TextInputType.phone, textInputAction: TextInputAction.done, onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'), decoration: InputDecoration( border: const OutlineInputBorder(), hintText: '01012345678', suffixIcon: _isPhoneVerified ? const Icon(Icons.check_circle, color: Colors.green) : null, ), enabled: !_isPhoneVerified, ), ), const SizedBox(width: 8), if (_isPhoneChanged && !_isPhoneVerified) ElevatedButton( onPressed: _isVerifying ? null : _sendCode, child: Text(_isCodeSent ? '재전송' : '인증요청'), ), const SizedBox(width: 8), OutlinedButton( onPressed: isUpdating ? null : () => _cancelEditing(profile), child: const Text('취소'), ), ], ), if (_isCodeSent && !_isPhoneVerified) ...[ const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _codeController, focusNode: _phoneCodeFocus, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, onSubmitted: (_) => _verifyCode(profile), decoration: const InputDecoration( border: OutlineInputBorder(), hintText: '인증번호 6자리', ), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _isVerifying ? null : () => _verifyCode(profile), child: const Text('확인'), ), ], ), ], if (_isPhoneChanged && !_isPhoneVerified) const Padding( padding: EdgeInsets.only(top: 8.0), child: Text( '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', style: TextStyle(color: Colors.orange, fontSize: 12), ), ), ], ); } Widget _buildContent(UserProfile profile, bool isUpdating) { return RefreshIndicator( onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), child: LayoutBuilder( builder: (context, constraints) { return Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1200), child: ListView( padding: const EdgeInsets.all(24.0), children: [ _buildHeaderCard(profile), const SizedBox(height: 28), _buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'name', label: '이름', value: profile.name, profile: profile, isUpdating: isUpdating, controller: _nameController!, ), const Divider(height: 24), _buildReadOnlyTile('이메일', profile.email), const Divider(height: 24), _buildPhoneEditor(profile, isUpdating), ], ), ), const SizedBox(height: 28), _buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'department', label: '소속', value: profile.department, profile: profile, isUpdating: isUpdating, controller: _departmentController!, ), const Divider(height: 24), _buildReadOnlyTile('구분', profile.affiliationType), if (profile.companyCode.isNotEmpty) ...[ const Divider(height: 24), _buildReadOnlyTile('회사코드', profile.companyCode), ], ], ), ), if (isUpdating || _isVerifying) ...[ const SizedBox(height: 24), const Center(child: CircularProgressIndicator()), ], ], ), ), ); }, ), ); } @override Widget build(BuildContext context) { final profileState = ref.watch(profileProvider); if (profileState.value != null) { _cachedProfile = profileState.value; } final profile = profileState.value ?? _cachedProfile; if (profile == null) { return Scaffold( appBar: AppBar(title: const Text('내 정보')), body: profileState.isLoading ? const Center(child: CircularProgressIndicator()) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('정보를 불러올 수 없습니다.'), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.read(profileProvider.notifier).loadProfile(), child: const Text('재시도'), ), ], ), ), ); } _ensureControllers(profile); final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final isUpdating = profileState.isLoading; return Scaffold( backgroundColor: _subtle, appBar: AppBar( title: Text( 'Baron 통합로그인', style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, foregroundColor: Colors.black, actions: [ IconButton( icon: const Icon(Icons.home_outlined), tooltip: '대시보드', onPressed: () => context.go('/'), ), IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: 'QR 스캔', onPressed: () => context.push('/scan'), ), IconButton( icon: const Icon(Icons.logout), tooltip: '로그아웃', onPressed: _logout, ), ], ), drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), body: Row( children: [ if (isWide) SizedBox( width: 240, child: _buildSideMenu(context), ), Expanded(child: _buildContent(profile, isUpdating)), ], ), ); } }