import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:userfront/i18n.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/logout_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/theme_toggle_button.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 final _log = Logger('ProfilePage'); UserProfile? _cachedProfile; String? _editingField; TextEditingController? _nameController; TextEditingController? _phoneController; TextEditingController? _departmentController; TextEditingController? _codeController; TextEditingController? _currentPasswordController; TextEditingController? _newPasswordController; TextEditingController? _confirmPasswordController; final FocusNode _nameFocus = FocusNode(); final FocusNode _departmentFocus = FocusNode(); final FocusNode _phoneFocus = FocusNode(); final FocusNode _phoneCodeFocus = FocusNode(); bool _isSavingField = false; String? _fieldSaveError; String _initialPhone = ''; bool _isPhoneChanged = false; bool _isPhoneVerified = false; bool _isCodeSent = false; bool _isVerifying = false; bool _isPasswordSaving = false; String? _passwordError; String? _passwordSuccess; bool _showCurrentPassword = false; bool _showNewPassword = false; bool _showConfirmPassword = false; bool _isDesktopSideMenuOpen = true; Map? _passwordPolicy; bool _isPasswordPolicyLoading = false; Color get _ink => Theme.of(context).colorScheme.onSurface; Color get _surface => Theme.of(context).colorScheme.surface; Color get _border => Theme.of(context).colorScheme.outlineVariant; Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; String _renderTranslatedText( String key, { String? fallback, Map values = const {}, }) { var text = tr(key, fallback: fallback); values.forEach((name, value) { text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); }); return text; } @override void initState() { super.initState(); _loadPasswordPolicy(); } Future _loadPasswordPolicy() async { setState(() { _isPasswordPolicyLoading = true; }); try { final policy = await AuthProxyService.fetchPasswordPolicy(); if (mounted) { setState(() { _passwordPolicy = policy; }); } } catch (_) { // 정책 조회 실패 시 기본 검증 규칙 사용 } finally { if (mounted) { setState(() { _isPasswordPolicyLoading = false; }); } } } String _buildPasswordPolicyDescription() { if (_isPasswordPolicyLoading) { return tr('msg.userfront.signup.policy.loading'); } final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12; final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _passwordPolicy?['lowercase'] ?? true; final requiresUpper = _passwordPolicy?['uppercase'] ?? false; final requiresNumber = _passwordPolicy?['number'] ?? true; final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true; final parts = [ _renderTranslatedText( 'msg.userfront.signup.policy.min_length', values: {'count': '$minLength'}, ), ]; if (minTypes > 0) { parts.add( _renderTranslatedText( 'msg.userfront.signup.policy.min_types', values: {'count': '$minTypes'}, ), ); } if (requiresLower) { parts.add(tr('msg.userfront.signup.policy.lowercase')); } if (requiresUpper) { parts.add(tr('msg.userfront.signup.policy.uppercase')); } if (requiresNumber) { parts.add(tr('msg.userfront.signup.policy.number')); } if (requiresSymbol) { parts.add(tr('msg.userfront.signup.policy.symbol')); } return _renderTranslatedText( 'msg.userfront.signup.policy.summary', values: {'rules': parts.join(", ")}, ); } void _debugLog( String event, { String? field, String? reason, bool? changed, bool? hasFocus, }) { final parts = ['event=$event']; if (field != null) parts.add('field=$field'); if (reason != null) parts.add('reason=$reason'); if (changed != null) parts.add('changed=$changed'); if (hasFocus != null) parts.add('hasFocus=$hasFocus'); if (_editingField != null) parts.add('editing=$_editingField'); _log.fine(parts.join(' ')); } @override void dispose() { _nameController?.dispose(); _phoneController?.dispose(); _departmentController?.dispose(); _codeController?.dispose(); _currentPasswordController?.dispose(); _newPasswordController?.dispose(); _confirmPasswordController?.dispose(); _nameFocus.dispose(); _departmentFocus.dispose(); _phoneFocus.dispose(); _phoneCodeFocus.dispose(); super.dispose(); } Future _logout() async { await LogoutService().logout(); } void _ensureControllers(UserProfile profile) { _nameController ??= TextEditingController(text: profile.name); _departmentController ??= TextEditingController(text: profile.department); _codeController ??= TextEditingController(); _currentPasswordController ??= TextEditingController(); _newPasswordController ??= TextEditingController(); _confirmPasswordController ??= 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(); } void _startEditing(String field, UserProfile profile) { _debugLog('start_editing', field: field); setState(() { _editingField = field; _fieldSaveError = null; 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; _fieldSaveError = null; }); } 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) { ToastService.info(tr('msg.userfront.profile.phone.code_sent')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { ToastService.error( tr( 'msg.userfront.profile.phone.send_failed', params: {'error': e.toString()}, ), ); } } } 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) { ToastService.success(tr('msg.userfront.profile.phone.verified')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { ToastService.error( tr( 'msg.userfront.profile.phone.verify_failed', params: {'error': e.toString()}, ), ); } } } Future _changePassword() async { if (_isPasswordSaving) return; final currentPassword = _currentPasswordController?.text.trim() ?? ''; final newPassword = _newPasswordController?.text.trim() ?? ''; final confirmPassword = _confirmPasswordController?.text.trim() ?? ''; if (currentPassword.isEmpty) { setState( () => _passwordError = tr( 'msg.userfront.profile.password.current_required', ), ); return; } if (newPassword.isEmpty) { setState( () => _passwordError = tr('msg.userfront.profile.password.new_required'), ); return; } final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12; final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0; final hasLower = RegExp(r'[a-z]').hasMatch(newPassword); final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword); final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword); final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword); int typeCount = 0; if (hasLower) typeCount++; if (hasUpper) typeCount++; if (hasNumber) typeCount++; if (hasSymbol) typeCount++; if (newPassword.length < minLength) { setState( () => _passwordError = tr( 'msg.userfront.reset.error.min_length', params: {'count': '$minLength'}, ), ); return; } if (minTypes > 0 && typeCount < minTypes) { setState( () => _passwordError = tr( 'msg.userfront.reset.error.min_types', params: {'count': '$minTypes'}, ), ); return; } if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) { setState( () => _passwordError = tr('msg.userfront.reset.error.lowercase'), ); return; } if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) { setState( () => _passwordError = tr('msg.userfront.reset.error.uppercase'), ); return; } if ((_passwordPolicy?['number'] ?? true) && !hasNumber) { setState(() => _passwordError = tr('msg.userfront.reset.error.number')); return; } if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) { setState(() => _passwordError = tr('msg.userfront.reset.error.symbol')); return; } if (newPassword != confirmPassword) { setState( () => _passwordError = tr('msg.userfront.profile.password.mismatch'), ); return; } setState(() { _passwordError = null; _passwordSuccess = null; _isPasswordSaving = true; }); try { await ref .read(profileRepositoryProvider) .changePassword( currentPassword: currentPassword, newPassword: newPassword, ); _currentPasswordController?.clear(); _newPasswordController?.clear(); _confirmPasswordController?.clear(); setState(() { _passwordSuccess = null; }); ToastService.success(tr('msg.userfront.profile.password.changed')); } catch (e) { final message = e.toString().replaceFirst('Exception: ', ''); setState(() { _passwordError = tr( 'msg.userfront.profile.password.change_failed', params: {'error': message}, ); }); ToastService.error( tr( 'msg.userfront.profile.password.change_failed', params: {'error': message}, ), ); } finally { if (mounted) { setState(() => _isPasswordSaving = false); } } } 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) { _debugLog('save_skip', reason: 'saving_in_flight'); return; } setState(() { _fieldSaveError = null; }); final currentField = _editingField!; final nextName = currentField == 'name' ? _nameController!.text.trim() : profile.name; final nextPhone = currentField == 'phone' ? _phoneController!.text.trim() : profile.phone; final nextDepartment = currentField == 'department' ? _departmentController!.text.trim() : profile.department; _debugLog('save_attempt', field: currentField); if (currentField == 'name' && nextName.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_name'); setState(() { _fieldSaveError = tr('msg.userfront.profile.name_required'); }); return; } if (currentField == 'department' && nextDepartment.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_department'); setState(() { _fieldSaveError = tr('msg.userfront.profile.department_required'); }); return; } if (currentField == 'phone') { if (nextPhone.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_phone'); setState(() { _fieldSaveError = tr('msg.userfront.profile.phone_required'); }); return; } if (_isPhoneChanged && !_isPhoneVerified) { _debugLog( 'save_skip', field: currentField, reason: 'phone_not_verified', ); setState(() { _fieldSaveError = tr('msg.userfront.profile.phone_verify_required'); }); return; } } if (!_hasFieldChanged(profile, currentField)) { _debugLog( 'save_skip', field: currentField, reason: 'unchanged', changed: false, ); setState(() { if (_editingField == 'phone') { _resetPhoneState(); } _editingField = null; }); return; } setState(() { _isSavingField = true; }); _debugLog('save_dispatch', field: currentField, changed: true); try { await ref .read(profileProvider.notifier) .updateProfile( name: nextName, phone: nextPhone, department: nextDepartment, ); if (mounted) { setState(() { if (currentField == 'phone') { _initialPhone = nextPhone; _resetPhoneState(); } _editingField = null; }); _debugLog('save_success', field: currentField); ToastService.success(tr('msg.userfront.profile.update_success')); } } catch (e) { _debugLog('save_failed', field: currentField, reason: e.toString()); if (mounted) { setState(() { _fieldSaveError = tr( 'msg.userfront.profile.update_failed', params: {'error': e.toString().replaceFirst('Exception: ', '')}, ); }); } } finally { if (mounted) { setState(() { _isSavingField = false; }); } } } Widget _buildSideMenu(BuildContext context) { return Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ ListTile( leading: const Icon(Icons.home_outlined), title: Text(tr('ui.userfront.nav.dashboard')), onTap: () => context.go(buildLocalizedHomePath(Uri.base)), ), ListTile( leading: const Icon(Icons.person_outline), title: Text(tr('ui.userfront.nav.profile')), selected: true, onTap: () => context.go('/profile'), ), ListTile( leading: const Icon(Icons.qr_code_scanner), title: Text(tr('ui.userfront.nav.qr_scan')), onTap: () => context.go('/scan'), ), const Divider(), ListTile( leading: const Icon(Icons.logout), title: Text(tr('ui.userfront.nav.logout')), onTap: _logout, ), ], ), ), const Padding( padding: EdgeInsets.only(bottom: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ ThemeToggleButton(), SizedBox(height: 8), LanguageSelector(compact: true), ], ), ), ], ); } Widget _buildSectionTitle(String title, String subtitle) { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( title, style: 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: TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, ), ), ], ), ); } Widget _buildHeaderCard(UserProfile profile) { final name = profile.name.isEmpty ? tr('msg.userfront.profile.name_missing') : profile.name; final email = profile.email.isEmpty ? tr('msg.userfront.profile.email_missing') : profile.email; final department = profile.department.isEmpty ? tr('msg.userfront.profile.department_missing') : 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.withValues(alpha: 10), 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( _renderTranslatedText( 'msg.userfront.profile.greeting', fallback: 'Hello, {{name}}.', values: {'name': name}, ), style: 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, tr('ui.userfront.profile.manage'), ), _buildInfoChip( Icons.apartment, profile.tenant?.name ?? 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.withValues(alpha: 8), 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( tr('ui.common.read_only'), 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; final isCompact = MediaQuery.of(context).size.width < 640; if (!isEditing) { return ListTile( contentPadding: EdgeInsets.zero, title: Text(label), subtitle: Text(displayValue), trailing: TextButton( key: Key('profile-$field-edit-button'), onPressed: isUpdating ? null : () => _startEditing(field, profile), child: Text(tr('ui.common.edit')), ), ); } final hasChanged = _hasFieldChanged(profile, field); final inputField = TextField( key: Key('profile-$field-input'), controller: controller, focusNode: field == 'name' ? _nameFocus : _departmentFocus, textInputAction: TextInputAction.done, onSubmitted: (_) => _saveField(profile), onChanged: (_) { setState(() { _fieldSaveError = null; }); }, decoration: InputDecoration( border: const OutlineInputBorder(), hintText: label, errorText: _fieldSaveError, ), ); final saveButton = ElevatedButton( key: Key('profile-$field-save-button'), onPressed: isUpdating || !hasChanged || _isSavingField ? null : () => _saveField(profile), child: _isSavingField ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(tr('ui.common.save')), ); final cancelButton = OutlinedButton( key: Key('profile-$field-cancel-button'), onPressed: isUpdating || _isSavingField ? null : () => _cancelEditing(profile), child: Text(tr('ui.common.cancel')), ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), if (isCompact) ...[ inputField, const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]), ] else Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: inputField), const SizedBox(width: 12), saveButton, const SizedBox(width: 8), cancelButton, ], ), ], ); } 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: Text(tr('ui.userfront.profile.phone.title')), subtitle: Text(displayValue), trailing: TextButton( onPressed: isUpdating ? null : () => _startEditing('phone', profile), child: Text(tr('ui.common.edit')), ), ); } final hasChanged = _hasFieldChanged(profile, 'phone'); final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('ui.userfront.profile.phone.title'), style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: _phoneController, focusNode: _phoneFocus, keyboardType: TextInputType.phone, textInputAction: TextInputAction.done, onSubmitted: (_) => _saveField(profile), onChanged: (_) { setState(() { _fieldSaveError = null; }); }, decoration: InputDecoration( border: const OutlineInputBorder(), hintText: '01012345678', errorText: _fieldSaveError, 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 ? tr('ui.common.resend') : tr('ui.userfront.profile.phone.request_code'), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: isUpdating || !canSave || _isSavingField ? null : () => _saveField(profile), child: _isSavingField ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(tr('ui.common.save')), ), const SizedBox(width: 8), OutlinedButton( onPressed: isUpdating || _isSavingField ? null : () => _cancelEditing(profile), child: Text(tr('ui.common.cancel')), ), ], ), 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: InputDecoration( border: const OutlineInputBorder(), hintText: tr('ui.userfront.profile.phone.code_hint'), ), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _isVerifying ? null : () => _verifyCode(profile), child: Text(tr('ui.common.confirm')), ), ], ), ], if (_isPhoneChanged && !_isPhoneVerified) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( tr('msg.userfront.profile.phone.verify_notice'), style: const TextStyle(color: Colors.orange, fontSize: 12), ), ), ], ); } Widget _buildPasswordSection() { return _buildCard( Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('ui.userfront.profile.password.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Text( tr('msg.userfront.profile.password.subtitle'), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( _buildPasswordPolicyDescription(), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ), const SizedBox(height: 16), TextField( controller: _currentPasswordController, obscureText: !_showCurrentPassword, decoration: InputDecoration( labelText: tr('ui.userfront.profile.password.current'), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon( _showCurrentPassword ? Icons.visibility_off : Icons.visibility, ), onPressed: () => setState(() { _showCurrentPassword = !_showCurrentPassword; }), ), ), ), const SizedBox(height: 12), TextField( controller: _newPasswordController, obscureText: !_showNewPassword, decoration: InputDecoration( labelText: tr('ui.userfront.profile.password.new'), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon( _showNewPassword ? Icons.visibility_off : Icons.visibility, ), onPressed: () => setState(() { _showNewPassword = !_showNewPassword; }), ), ), ), const SizedBox(height: 12), TextField( controller: _confirmPasswordController, obscureText: !_showConfirmPassword, decoration: InputDecoration( labelText: tr('ui.userfront.profile.password.confirm'), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon( _showConfirmPassword ? Icons.visibility_off : Icons.visibility, ), onPressed: () => setState(() { _showConfirmPassword = !_showConfirmPassword; }), ), ), ), if (_passwordError != null) ...[ const SizedBox(height: 12), Text(_passwordError!, style: const TextStyle(color: Colors.red)), ], if (_passwordSuccess != null) ...[ const SizedBox(height: 12), Text( _passwordSuccess!, style: const TextStyle(color: Colors.green), ), ], const SizedBox(height: 16), Row( children: [ ElevatedButton( onPressed: _isPasswordSaving ? null : _changePassword, child: _isPasswordSaving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(tr('ui.userfront.profile.password.change')), ), const SizedBox(width: 12), TextButton( onPressed: () => context.go('/recovery'), child: Text(tr('ui.userfront.profile.password.forgot')), ), ], ), ], ), ); } 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( tr('ui.userfront.profile.section.basic'), tr('msg.userfront.profile.section.basic'), ), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'name', label: tr('ui.userfront.profile.field.name'), value: profile.name, profile: profile, isUpdating: isUpdating, controller: _nameController!, ), const Divider(height: 24), _buildReadOnlyTile( tr('ui.userfront.profile.field.email'), profile.email, ), const Divider(height: 24), _buildPhoneEditor(profile, isUpdating), ], ), ), const SizedBox(height: 28), _buildSectionTitle( tr('ui.userfront.profile.section.organization'), tr('msg.userfront.profile.section.organization'), ), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'department', label: tr('ui.userfront.profile.field.department'), value: profile.department, profile: profile, isUpdating: isUpdating, controller: _departmentController!, ), const Divider(height: 24), _buildReadOnlyTile( tr('ui.userfront.profile.field.affiliation'), profile.affiliationType, ), if (profile.tenant != null) ...[ const Divider(height: 24), _buildReadOnlyTile( tr('ui.userfront.profile.field.tenant'), profile.tenant!.name, ), ], if (profile.tenant?.slug.isNotEmpty ?? false) ...[ const Divider(height: 24), _buildReadOnlyTile( tr('ui.userfront.profile.field.tenant_slug'), profile.tenant!.slug, ), ], ], ), ), const SizedBox(height: 28), _buildSectionTitle( tr('ui.userfront.profile.section.security'), tr('msg.userfront.profile.section.security'), ), const SizedBox(height: 12), _buildPasswordSection(), 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: Text(tr('ui.userfront.nav.profile'))), body: profileState.isLoading ? const Center(child: CircularProgressIndicator()) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(tr('msg.userfront.profile.load_failed')), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.read(profileProvider.notifier).loadProfile(), child: Text(tr('ui.common.retry')), ), ], ), ), ); } _ensureControllers(profile); final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final isUpdating = profileState.isLoading; return Scaffold( backgroundColor: _subtle, appBar: AppBar( leading: isWide ? IconButton( icon: Icon( _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, ), tooltip: _isDesktopSideMenuOpen ? tr('ui.common.collapse') : '펼치기', onPressed: () { setState(() { _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; }); }, ) : Builder( builder: (context) => IconButton( icon: const Icon(Icons.menu), tooltip: MaterialLocalizations.of( context, ).openAppDrawerTooltip, onPressed: () => Scaffold.of(context).openDrawer(), ), ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), actions: [ const ThemeToggleButton(compact: true), IconButton( icon: const Icon(Icons.home_outlined), tooltip: tr('ui.userfront.nav.dashboard'), onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: tr('ui.userfront.nav.qr_scan'), onPressed: () => context.push('/scan'), ), IconButton( icon: const Icon(Icons.logout), tooltip: tr('ui.userfront.nav.logout'), onPressed: _logout, ), ], ), drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), body: Row( children: [ if (isWide && _isDesktopSideMenuOpen) SizedBox(width: 240, child: _buildSideMenu(context)), Expanded(child: _buildContent(profile, isUpdating)), ], ), ); } }