forked from baron/baron-sso
프로필 자동저장 제거 및 명시적 저장 UX 개선
This commit is contained in:
@@ -38,12 +38,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final FocusNode _departmentFocus = FocusNode();
|
final FocusNode _departmentFocus = FocusNode();
|
||||||
final FocusNode _phoneFocus = FocusNode();
|
final FocusNode _phoneFocus = FocusNode();
|
||||||
final FocusNode _phoneCodeFocus = FocusNode();
|
final FocusNode _phoneCodeFocus = FocusNode();
|
||||||
bool _nameTouched = false;
|
|
||||||
bool _departmentTouched = false;
|
|
||||||
bool _phoneTouched = false;
|
|
||||||
bool _phoneCodeTouched = false;
|
|
||||||
bool _isSavingField = false;
|
bool _isSavingField = false;
|
||||||
String? _skipAutoSaveField;
|
String? _fieldSaveError;
|
||||||
|
|
||||||
String _initialPhone = '';
|
String _initialPhone = '';
|
||||||
bool _isPhoneChanged = false;
|
bool _isPhoneChanged = false;
|
||||||
@@ -61,10 +57,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameFocus.addListener(_onNameFocusChange);
|
|
||||||
_departmentFocus.addListener(_onDepartmentFocusChange);
|
|
||||||
_phoneFocus.addListener(_onPhoneFocusChange);
|
|
||||||
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _debugLog(
|
void _debugLog(
|
||||||
@@ -83,63 +75,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_log.fine(parts.join(' '));
|
_log.fine(parts.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNameFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
|
||||||
});
|
|
||||||
} else if (_nameFocus.hasFocus) {
|
|
||||||
_nameTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDepartmentFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
_debugLog(
|
|
||||||
'department_focus_change',
|
|
||||||
field: 'department',
|
|
||||||
hasFocus: _departmentFocus.hasFocus,
|
|
||||||
);
|
|
||||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
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) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
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) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _handlePhoneFocusChange(profile);
|
|
||||||
});
|
|
||||||
} else if (_phoneCodeFocus.hasFocus) {
|
|
||||||
_phoneCodeTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController?.dispose();
|
_nameController?.dispose();
|
||||||
@@ -210,14 +145,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isCodeSent = false;
|
_isCodeSent = false;
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
_codeController?.clear();
|
_codeController?.clear();
|
||||||
_phoneTouched = false;
|
|
||||||
_phoneCodeTouched = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startEditing(String field, UserProfile profile) {
|
void _startEditing(String field, UserProfile profile) {
|
||||||
_debugLog('start_editing', field: field);
|
_debugLog('start_editing', field: field);
|
||||||
setState(() {
|
setState(() {
|
||||||
_editingField = field;
|
_editingField = field;
|
||||||
|
_fieldSaveError = null;
|
||||||
if (field == 'name') {
|
if (field == 'name') {
|
||||||
_nameController?.text = profile.name;
|
_nameController?.text = profile.name;
|
||||||
} else if (field == 'department') {
|
} else if (field == 'department') {
|
||||||
@@ -252,8 +186,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
_fieldSaveError = null;
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,9 +240,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
|
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_editingField == 'phone') {
|
|
||||||
await _saveField(profile);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isVerifying = false);
|
setState(() => _isVerifying = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -389,64 +319,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
|
||||||
if (_editingField != field) return;
|
|
||||||
if (_skipAutoSaveField == field) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
|
|
||||||
_skipAutoSaveField = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isVerifying) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'verifying');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isSavingField) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_hasFieldChanged(profile, field)) {
|
|
||||||
_debugLog(
|
|
||||||
'autosave_skip',
|
|
||||||
field: field,
|
|
||||||
reason: 'unchanged',
|
|
||||||
changed: false,
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
if (field == 'phone') {
|
|
||||||
_resetPhoneState();
|
|
||||||
}
|
|
||||||
_editingField = null;
|
|
||||||
if (field == 'name') {
|
|
||||||
_nameTouched = false;
|
|
||||||
} else if (field == 'department') {
|
|
||||||
_departmentTouched = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_debugLog('autosave_trigger', field: field, changed: true);
|
|
||||||
_saveField(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handlePhoneFocusChange(UserProfile profile) {
|
|
||||||
if (_editingField != 'phone') return;
|
|
||||||
if (_skipAutoSaveField == 'phone') {
|
|
||||||
_skipAutoSaveField = null;
|
|
||||||
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) {
|
bool _hasFieldChanged(UserProfile profile, String field) {
|
||||||
if (field == 'name') {
|
if (field == 'name') {
|
||||||
return (_nameController?.text.trim() ?? '') != profile.name;
|
return (_nameController?.text.trim() ?? '') != profile.name;
|
||||||
@@ -466,6 +338,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_debugLog('save_skip', reason: 'saving_in_flight');
|
_debugLog('save_skip', reason: 'saving_in_flight');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
|
||||||
final currentField = _editingField!;
|
final currentField = _editingField!;
|
||||||
|
|
||||||
final nextName = currentField == 'name'
|
final nextName = currentField == 'name'
|
||||||
@@ -482,26 +359,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
if (currentField == 'name' && nextName.isEmpty) {
|
if (currentField == 'name' && nextName.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
|
_fieldSaveError = tr('msg.userfront.profile.name_required');
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentField == 'department' && nextDepartment.isEmpty) {
|
if (currentField == 'department' && nextDepartment.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr('msg.userfront.profile.department_required');
|
||||||
content: Text(tr('msg.userfront.profile.department_required')),
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentField == 'phone') {
|
if (currentField == 'phone') {
|
||||||
if (nextPhone.isEmpty) {
|
if (nextPhone.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
|
_fieldSaveError = tr('msg.userfront.profile.phone_required');
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||||
@@ -510,11 +385,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
field: currentField,
|
field: currentField,
|
||||||
reason: 'phone_not_verified',
|
reason: 'phone_not_verified',
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
|
||||||
content: Text(tr('msg.userfront.profile.phone_verify_required')),
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,13 +404,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isSavingField = true;
|
setState(() {
|
||||||
|
_isSavingField = true;
|
||||||
|
});
|
||||||
|
|
||||||
_debugLog('save_dispatch', field: currentField, changed: true);
|
_debugLog('save_dispatch', field: currentField, changed: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,8 +429,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
_debugLog('save_success', field: currentField);
|
_debugLog('save_success', field: currentField);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -566,19 +438,19 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_debugLog('save_failed', field: currentField, reason: e.toString());
|
_debugLog('save_failed', field: currentField, reason: e.toString());
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr(
|
||||||
content: Text(
|
'msg.userfront.profile.update_failed',
|
||||||
tr(
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
'msg.userfront.profile.update_failed',
|
);
|
||||||
params: {'error': e.toString()},
|
});
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isSavingField = false;
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSavingField = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,13 +665,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasChanged = _hasFieldChanged(profile, field);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -807,23 +681,38 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
onSubmitted: (_) => _saveField(profile),
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
hintText: label,
|
hintText: label,
|
||||||
|
errorText: _fieldSaveError,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Listener(
|
ElevatedButton(
|
||||||
onPointerDown: (_) {
|
key: Key('profile-$field-save-button'),
|
||||||
_skipAutoSaveField = field;
|
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||||
},
|
? null
|
||||||
child: OutlinedButton(
|
: () => _saveField(profile),
|
||||||
key: Key('profile-$field-cancel-button'),
|
child: _isSavingField
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
? const SizedBox(
|
||||||
child: Text(tr('ui.common.cancel')),
|
width: 16,
|
||||||
),
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(tr('ui.common.save')),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
key: Key('profile-$field-cancel-button'),
|
||||||
|
onPressed: isUpdating || _isSavingField ? null : () => _cancelEditing(profile),
|
||||||
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -847,6 +736,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasChanged = _hasFieldChanged(profile, 'phone');
|
||||||
|
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -856,7 +748,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -864,10 +756,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
focusNode: _phoneFocus,
|
focusNode: _phoneFocus,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
|
onSubmitted: (_) => _saveField(profile),
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
hintText: '01012345678',
|
hintText: '01012345678',
|
||||||
|
errorText: _fieldSaveError,
|
||||||
suffixIcon: _isPhoneVerified
|
suffixIcon: _isPhoneVerified
|
||||||
? const Icon(Icons.check_circle, color: Colors.green)
|
? const Icon(Icons.check_circle, color: Colors.green)
|
||||||
: null,
|
: null,
|
||||||
@@ -886,14 +784,22 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Listener(
|
ElevatedButton(
|
||||||
onPointerDown: (_) {
|
onPressed: isUpdating || !canSave || _isSavingField
|
||||||
_skipAutoSaveField = 'phone';
|
? null
|
||||||
},
|
: () => _saveField(profile),
|
||||||
child: OutlinedButton(
|
child: _isSavingField
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
? const SizedBox(
|
||||||
child: Text(tr('ui.common.cancel')),
|
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')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
120
userfront/test/profile_page_edit_flow_test.dart
Normal file
120
userfront/test/profile_page_edit_flow_test.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||||
|
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||||
|
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
|
||||||
|
|
||||||
|
// Mocking the profile notifier
|
||||||
|
class MockProfileNotifier extends ProfileNotifier {
|
||||||
|
UserProfile? _profile;
|
||||||
|
bool updateCalled = false;
|
||||||
|
String? updatedName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> build() async {
|
||||||
|
_profile = UserProfile(
|
||||||
|
id: 'test-id',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Original Name',
|
||||||
|
phone: '01012345678',
|
||||||
|
department: 'Dev',
|
||||||
|
affiliationType: 'employee',
|
||||||
|
companyCode: 'C100',
|
||||||
|
);
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> loadProfile() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = AsyncValue.data(_profile);
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateProfile({String? name, String? phone, String? department}) async {
|
||||||
|
updateCalled = true;
|
||||||
|
updatedName = name;
|
||||||
|
_profile = _profile!.copyWith(
|
||||||
|
name: name ?? _profile!.name,
|
||||||
|
phone: phone ?? _profile!.phone,
|
||||||
|
department: department ?? _profile!.department,
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)', (tester) async {
|
||||||
|
final recordedErrors = <FlutterErrorDetails>[];
|
||||||
|
final previousOnError = FlutterError.onError;
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
final text = details.exceptionAsString();
|
||||||
|
if (text.contains('A RenderFlex overflowed')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordedErrors.add(details);
|
||||||
|
};
|
||||||
|
addTearDown(() {
|
||||||
|
FlutterError.onError = previousOnError;
|
||||||
|
});
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(1920, 1080);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final mockNotifier = MockProfileNotifier();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
profileProvider.overrideWith(() => mockNotifier),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(body: ProfilePage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 1. Entering edit mode
|
||||||
|
final editButton = find.byKey(const Key('profile-name-edit-button'));
|
||||||
|
expect(editButton, findsOneWidget);
|
||||||
|
await tester.tap(editButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final inputField = find.byKey(const Key('profile-name-input'));
|
||||||
|
expect(inputField, findsOneWidget);
|
||||||
|
|
||||||
|
// 2. Testing cancel flow
|
||||||
|
await tester.enterText(inputField, 'Changed Name');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
|
||||||
|
await tester.tap(cancelButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// After cancellation, the field should be read-only again.
|
||||||
|
expect(find.byKey(const Key('profile-name-input')), findsNothing);
|
||||||
|
// Find text could be part of ListTile
|
||||||
|
expect(find.text('Original Name'), findsWidgets);
|
||||||
|
|
||||||
|
// 3. Re-enter edit mode and explicitly save
|
||||||
|
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('profile-name-input')), 'Saved Name');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final saveButton = find.byKey(const Key('profile-name-save-button'));
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify the mock received the update
|
||||||
|
expect(mockNotifier.updateCalled, isTrue);
|
||||||
|
expect(mockNotifier.updatedName, 'Saved Name');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user