From 0bb41ae3541762be7baab2ded3c66fd4c6732b53 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 20 Mar 2026 09:57:39 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=A0=80=EC=9E=A5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=20=EC=A0=80=EC=9E=A5=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/pages/profile_page.dart | 274 ++++++------------ .../test/profile_page_edit_flow_test.dart | 120 ++++++++ 2 files changed, 210 insertions(+), 184 deletions(-) create mode 100644 userfront/test/profile_page_edit_flow_test.dart diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index dd741004..705b9752 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -38,12 +38,8 @@ class _ProfilePageState extends ConsumerState { 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? _skipAutoSaveField; + String? _fieldSaveError; String _initialPhone = ''; bool _isPhoneChanged = false; @@ -61,10 +57,6 @@ class _ProfilePageState extends ConsumerState { @override void initState() { super.initState(); - _nameFocus.addListener(_onNameFocusChange); - _departmentFocus.addListener(_onDepartmentFocusChange); - _phoneFocus.addListener(_onPhoneFocusChange); - _phoneCodeFocus.addListener(_onPhoneCodeFocusChange); } void _debugLog( @@ -83,63 +75,6 @@ class _ProfilePageState extends ConsumerState { _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 void dispose() { _nameController?.dispose(); @@ -210,14 +145,13 @@ class _ProfilePageState extends ConsumerState { _isCodeSent = false; _isVerifying = false; _codeController?.clear(); - _phoneTouched = false; - _phoneCodeTouched = false; } 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') { @@ -252,8 +186,7 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; + _fieldSaveError = null; }); } @@ -307,9 +240,6 @@ class _ProfilePageState extends ConsumerState { SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))), ); } - if (_editingField == 'phone') { - await _saveField(profile); - } } catch (e) { setState(() => _isVerifying = false); if (mounted) { @@ -389,64 +319,6 @@ class _ProfilePageState extends ConsumerState { } } - 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) { if (field == 'name') { return (_nameController?.text.trim() ?? '') != profile.name; @@ -466,6 +338,11 @@ class _ProfilePageState extends ConsumerState { _debugLog('save_skip', reason: 'saving_in_flight'); return; } + + setState(() { + _fieldSaveError = null; + }); + final currentField = _editingField!; final nextName = currentField == 'name' @@ -482,26 +359,24 @@ class _ProfilePageState extends ConsumerState { if (currentField == 'name' && nextName.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_name'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.name_required'))), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.name_required'); + }); return; } if (currentField == 'department' && nextDepartment.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_department'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.profile.department_required')), - ), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.department_required'); + }); return; } if (currentField == 'phone') { if (nextPhone.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_phone'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.phone_required'); + }); return; } if (_isPhoneChanged && !_isPhoneVerified) { @@ -510,11 +385,9 @@ class _ProfilePageState extends ConsumerState { field: currentField, reason: 'phone_not_verified', ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.profile.phone_verify_required')), - ), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.phone_verify_required'); + }); return; } } @@ -531,13 +404,14 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; }); return; } - _isSavingField = true; + setState(() { + _isSavingField = true; + }); + _debugLog('save_dispatch', field: currentField, changed: true); try { @@ -555,8 +429,6 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; }); _debugLog('save_success', field: currentField); ScaffoldMessenger.of(context).showSnackBar( @@ -566,19 +438,19 @@ class _ProfilePageState extends ConsumerState { } catch (e) { _debugLog('save_failed', field: currentField, reason: e.toString()); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.update_failed', - params: {'error': e.toString()}, - ), - ), - ), - ); + setState(() { + _fieldSaveError = tr( + 'msg.userfront.profile.update_failed', + params: {'error': e.toString().replaceFirst('Exception: ', '')}, + ); + }); } } finally { - _isSavingField = false; + if (mounted) { + setState(() { + _isSavingField = false; + }); + } } } @@ -793,13 +665,15 @@ class _ProfilePageState extends ConsumerState { ); } + final hasChanged = _hasFieldChanged(profile, field); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( @@ -807,23 +681,38 @@ class _ProfilePageState extends ConsumerState { controller: controller, focusNode: field == 'name' ? _nameFocus : _departmentFocus, textInputAction: TextInputAction.done, - onSubmitted: (_) => _autoSaveIfEditing(profile, field), + onSubmitted: (_) => _saveField(profile), + onChanged: (_) { + setState(() { + _fieldSaveError = null; + }); + }, decoration: InputDecoration( border: const OutlineInputBorder(), hintText: label, + errorText: _fieldSaveError, ), ), ), const SizedBox(width: 12), - Listener( - onPointerDown: (_) { - _skipAutoSaveField = field; - }, - child: OutlinedButton( - key: Key('profile-$field-cancel-button'), - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), + 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')), + ), + 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 { ); } + final hasChanged = _hasFieldChanged(profile, 'phone'); + final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -856,7 +748,7 @@ class _ProfilePageState extends ConsumerState { ), const SizedBox(height: 8), Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( @@ -864,10 +756,16 @@ class _ProfilePageState extends ConsumerState { focusNode: _phoneFocus, keyboardType: TextInputType.phone, textInputAction: TextInputAction.done, - onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'), + 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, @@ -886,14 +784,22 @@ class _ProfilePageState extends ConsumerState { ), ), const SizedBox(width: 8), - Listener( - onPointerDown: (_) { - _skipAutoSaveField = 'phone'; - }, - child: OutlinedButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), + 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')), ), ], ), diff --git a/userfront/test/profile_page_edit_flow_test.dart b/userfront/test/profile_page_edit_flow_test.dart new file mode 100644 index 00000000..0abfe01f --- /dev/null +++ b/userfront/test/profile_page_edit_flow_test.dart @@ -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 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 loadProfile() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(_profile); + return _profile; + } + + @override + Future 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 = []; + 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'); + }); +} From 5c995a5b4d81839398acf77f011bf5d8f6ab7fbb Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 20 Mar 2026 10:50:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?uf=20=EC=95=A1=EC=85=98=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?SnackBar=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/lib/core/ui/toast_service.dart | 234 ++++++++++++++++++ .../presentation/create_user_screen.dart | 19 +- .../presentation/user_management_screen.dart | 16 +- .../auth/presentation/consent_screen.dart | 18 +- .../presentation/forgot_password_screen.dart | 12 +- .../auth/presentation/login_screen.dart | 9 +- .../presentation/qr_scan_screen_stub.dart | 12 +- .../presentation/reset_password_screen.dart | 12 +- .../presentation/dashboard_screen.dart | 53 +--- .../presentation/pages/profile_page.dart | 58 +++-- userfront/lib/main.dart | 6 + 11 files changed, 308 insertions(+), 141 deletions(-) create mode 100644 userfront/lib/core/ui/toast_service.dart diff --git a/userfront/lib/core/ui/toast_service.dart b/userfront/lib/core/ui/toast_service.dart new file mode 100644 index 00000000..560f6e94 --- /dev/null +++ b/userfront/lib/core/ui/toast_service.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +enum ToastType { success, error, info } + +class _ToastItem { + const _ToastItem({ + required this.id, + required this.message, + required this.type, + }); + + final String id; + final String message; + final ToastType type; +} + +class ToastService { + static const Duration _displayDuration = Duration(milliseconds: 3000); + static final ValueNotifier> _toasts = + ValueNotifier>(<_ToastItem>[]); + + static void success(String message) { + show(message, type: ToastType.success); + } + + static void error(String message) { + show(message, type: ToastType.error); + } + + static void info(String message) { + show(message, type: ToastType.info); + } + + static void show(String message, {ToastType type = ToastType.success}) { + final trimmed = message.trim(); + if (trimmed.isEmpty) { + return; + } + + final item = _ToastItem( + id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}', + message: trimmed, + type: type, + ); + + _toasts.value = [..._toasts.value, item]; + + unawaited( + Future.delayed(_displayDuration, () { + _remove(item.id); + }), + ); + } + + static void _remove(String id) { + final next = _toasts.value.where((toast) => toast.id != id).toList(); + if (next.length == _toasts.value.length) { + return; + } + _toasts.value = next; + } +} + +class ToastViewport extends StatelessWidget { + const ToastViewport({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: true, + child: SafeArea( + child: ValueListenableBuilder>( + valueListenable: ToastService._toasts, + builder: (context, toasts, _) { + if (toasts.isEmpty) { + return const SizedBox.shrink(); + } + + final media = MediaQuery.of(context); + final width = math.min(320.0, media.size.width - 32); + + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 16, bottom: 16), + child: SizedBox( + width: width > 0 ? width : 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final toast in toasts) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _ToastCard(item: toast), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _ToastCard extends StatefulWidget { + const _ToastCard({required this.item}); + + final _ToastItem item; + + @override + State<_ToastCard> createState() => _ToastCardState(); +} + +class _ToastCardState extends State<_ToastCard> { + bool _visible = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() { + _visible = true; + }); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = _toastColorScheme(widget.item.type); + final icon = _toastIcon(widget.item.type); + + return AnimatedSlide( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + offset: _visible ? Offset.zero : const Offset(1, 0), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _visible ? 1 : 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: scheme.border), + boxShadow: const [ + BoxShadow( + color: Color(0x26000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: scheme.foreground), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.item.message, + style: TextStyle( + color: scheme.foreground, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + _ToastColorScheme _toastColorScheme(ToastType type) { + switch (type) { + case ToastType.success: + return const _ToastColorScheme( + background: Color(0xFFECFDF5), + border: Color(0xFFA7F3D0), + foreground: Color(0xFF065F46), + ); + case ToastType.error: + return const _ToastColorScheme( + background: Color(0xFFFFF1F2), + border: Color(0xFFFDA4AF), + foreground: Color(0xFF9F1239), + ); + case ToastType.info: + return const _ToastColorScheme( + background: Color(0xFFEFF6FF), + border: Color(0xFFBFDBFE), + foreground: Color(0xFF1E40AF), + ); + } + } + + IconData _toastIcon(ToastType type) { + switch (type) { + case ToastType.success: + return Icons.check_circle_outline; + case ToastType.error: + return Icons.error_outline; + case ToastType.info: + return Icons.info_outline; + } + } +} + +class _ToastColorScheme { + const _ToastColorScheme({ + required this.background, + required this.border, + required this.foreground, + }); + + final Color background; + final Color border; + final Color foreground; +} diff --git a/userfront/lib/features/admin/presentation/create_user_screen.dart b/userfront/lib/features/admin/presentation/create_user_screen.dart index 368cc681..e08e94d5 100644 --- a/userfront/lib/features/admin/presentation/create_user_screen.dart +++ b/userfront/lib/features/admin/presentation/create_user_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/i18n/locale_utils.dart'; +import '../../../../core/ui/toast_service.dart'; class CreateUserScreen extends StatefulWidget { const CreateUserScreen({super.key}); @@ -86,12 +87,7 @@ class _CreateUserScreenState extends State { } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid Password. Access Denied.'), - backgroundColor: Colors.red, - ), - ); + ToastService.error('Invalid Password. Access Denied.'); context.go(buildLocalizedHomePath(Uri.base)); // Kick out } } @@ -144,12 +140,7 @@ class _CreateUserScreenState extends State { ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('User created successfully!'), - backgroundColor: Colors.green, - ), - ); + ToastService.success('User created successfully!'); _formKey.currentState!.reset(); _loginIdController.clear(); _emailController.clear(); @@ -158,9 +149,7 @@ class _CreateUserScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), - ); + ToastService.error('Error: $e'); } } finally { if (mounted) setState(() => _isLoading = false); diff --git a/userfront/lib/features/admin/presentation/user_management_screen.dart b/userfront/lib/features/admin/presentation/user_management_screen.dart index 50274f13..d1d4168f 100644 --- a/userfront/lib/features/admin/presentation/user_management_screen.dart +++ b/userfront/lib/features/admin/presentation/user_management_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'dart:async'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/i18n/locale_utils.dart'; +import '../../../../core/ui/toast_service.dart'; class UserManagementScreen extends StatefulWidget { const UserManagementScreen({super.key}); @@ -108,12 +109,7 @@ class _UserManagementScreenState extends State } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid Password'), - backgroundColor: Colors.red, - ), - ); + ToastService.error('Invalid Password'); context.go(buildLocalizedHomePath(Uri.base)); } } @@ -343,16 +339,12 @@ class _UserManagementScreenState extends State // --- UI Helpers --- void _showError(String msg) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red)); + ToastService.error(msg); } void _showSuccess(String msg) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green)); + ToastService.success(msg); } @override diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 9d7d8003..327b63ff 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart'; import 'package:userfront/core/i18n/locale_utils.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/web_window.dart'; +import 'package:userfront/core/ui/toast_service.dart'; class ConsentScreen extends StatefulWidget { final String consentChallenge; @@ -187,16 +188,11 @@ class _ConsentScreenState extends State { } catch (e) { setState(() => _isSubmitting = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.consent.cancel.error', - fallback: - 'An error occurred while cancelling consent: {{error}}', - params: {'error': '$e'}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.consent.cancel.error', + fallback: 'An error occurred while cancelling consent: {{error}}', + params: {'error': '$e'}, ), ); } @@ -419,7 +415,7 @@ class _ConsentScreenState extends State { ) : Text( tr('ui.userfront.consent.accept'), - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/forgot_password_screen.dart b/userfront/lib/features/auth/presentation/forgot_password_screen.dart index b3fe1d96..8c01e4cd 100644 --- a/userfront/lib/features/auth/presentation/forgot_password_screen.dart +++ b/userfront/lib/features/auth/presentation/forgot_password_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/ui/toast_service.dart'; import 'package:userfront/i18n.dart'; class ForgotPasswordScreen extends StatefulWidget { @@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State { drySend: _drySendEnabled, ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.forgot.sent')), - backgroundColor: Colors.green, - ), - ); + ToastService.success(tr('msg.userfront.forgot.sent')); Navigator.of(context).pop(); } } catch (e) { @@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State { } void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); } bool _parseBoolParam(String? value) { diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 906b7ba5..89365e12 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -18,6 +18,7 @@ import '../domain/cookie_session_policy.dart'; import '../domain/login_link_route_policy.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; +import '../../../core/ui/toast_service.dart'; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; @@ -1153,9 +1154,7 @@ class _LoginScreenState extends ConsumerState void _showError(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); try { AuthProxyService.logError(message); } catch (e) { @@ -1165,9 +1164,7 @@ class _LoginScreenState extends ConsumerState void _showInfo(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.green), - ); + ToastService.success(message); } void _logTokenDetails(String jwt) { diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart index cd524661..75962f23 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:userfront/i18n.dart'; +import 'package:userfront/core/ui/toast_service.dart'; import 'qr_scan_route.dart'; @@ -23,15 +24,8 @@ class _QRScanScreenState extends State { void _submit() { final raw = _controller.text.trim(); if (raw.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.qr.permission_required', - fallback: '카메라 권한이 필요합니다.', - ), - ), - ), + ToastService.info( + tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'), ); return; } diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index d9ebcb39..938831a2 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/ui/toast_service.dart'; import 'package:userfront/i18n.dart'; class ResetPasswordScreen extends StatefulWidget { @@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State { ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.reset.success')), - backgroundColor: Colors.green, - ), - ); + ToastService.success(tr('msg.userfront.reset.success')); context.go(buildLocalizedSigninPath(Uri.base)); } } catch (e) { @@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State { } void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); } String _buildPolicyDescription() { diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 3e30856f..342d0940 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -15,6 +15,7 @@ import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/ui/layout_breakpoints.dart'; +import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; import '../domain/models.dart' hide LinkedRp; @@ -104,14 +105,10 @@ class _DashboardScreenState extends ConsumerState { try { await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.dashboard.revoke.success', - params: {'app': appName}, - ), - ), + ToastService.success( + tr( + 'msg.userfront.dashboard.revoke.success', + params: {'app': appName}, ), ); setState(() { @@ -121,15 +118,8 @@ class _DashboardScreenState extends ConsumerState { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.dashboard.revoke.error', - params: {'error': '$e'}, - ), - ), - ), + ToastService.error( + tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}), ); } } finally { @@ -547,12 +537,8 @@ class _DashboardScreenState extends ConsumerState { : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr('msg.userfront.dashboard.session_id_copied'), - ), - ), + ToastService.info( + tr('msg.userfront.dashboard.session_id_copied'), ); } }, @@ -626,12 +612,8 @@ class _DashboardScreenState extends ConsumerState { : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr('msg.userfront.dashboard.session_id_copied'), - ), - ), + ToastService.info( + tr('msg.userfront.dashboard.session_id_copied'), ); } }, @@ -1280,7 +1262,6 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { - final messenger = ScaffoldMessenger.of(context); final itemUrl = item.url; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); @@ -1290,18 +1271,10 @@ class _DashboardScreenState extends ConsumerState { await launchUrl(uri); return; } - messenger.showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.dashboard.link_open_error')), - ), - ); + ToastService.error(tr('msg.userfront.dashboard.link_open_error')); } else { if (!mounted) return; - messenger.showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.dashboard.link_missing')), - ), - ); + ToastService.info(tr('msg.userfront.dashboard.link_missing')); } }, child: opaqueCard, diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 705b9752..c489e2a8 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; +import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; @@ -202,21 +203,15 @@ class _ProfilePageState extends ConsumerState { _isVerifying = false; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))), - ); + ToastService.info(tr('msg.userfront.profile.phone.code_sent')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.phone.send_failed', - params: {'error': e.toString()}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.profile.phone.send_failed', + params: {'error': e.toString()}, ), ); } @@ -236,21 +231,15 @@ class _ProfilePageState extends ConsumerState { _isVerifying = false; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))), - ); + ToastService.success(tr('msg.userfront.profile.phone.verified')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.phone.verify_failed', - params: {'error': e.toString()}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.profile.phone.verify_failed', + params: {'error': e.toString()}, ), ); } @@ -302,8 +291,9 @@ class _ProfilePageState extends ConsumerState { _newPasswordController?.clear(); _confirmPasswordController?.clear(); setState(() { - _passwordSuccess = tr('msg.userfront.profile.password.changed'); + _passwordSuccess = null; }); + ToastService.success(tr('msg.userfront.profile.password.changed')); } catch (e) { final message = e.toString().replaceFirst('Exception: ', ''); setState(() { @@ -312,6 +302,12 @@ class _ProfilePageState extends ConsumerState { params: {'error': message}, ); }); + ToastService.error( + tr( + 'msg.userfront.profile.password.change_failed', + params: {'error': message}, + ), + ); } finally { if (mounted) { setState(() => _isPasswordSaving = false); @@ -338,7 +334,7 @@ class _ProfilePageState extends ConsumerState { _debugLog('save_skip', reason: 'saving_in_flight'); return; } - + setState(() { _fieldSaveError = null; }); @@ -411,7 +407,7 @@ class _ProfilePageState extends ConsumerState { setState(() { _isSavingField = true; }); - + _debugLog('save_dispatch', field: currentField, changed: true); try { @@ -431,9 +427,7 @@ class _ProfilePageState extends ConsumerState { _editingField = null; }); _debugLog('save_success', field: currentField); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.update_success'))), - ); + ToastService.success(tr('msg.userfront.profile.update_success')); } } catch (e) { _debugLog('save_failed', field: currentField, reason: e.toString()); @@ -711,7 +705,9 @@ class _ProfilePageState extends ConsumerState { const SizedBox(width: 8), OutlinedButton( key: Key('profile-$field-cancel-button'), - onPressed: isUpdating || _isSavingField ? null : () => _cancelEditing(profile), + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), child: Text(tr('ui.common.cancel')), ), ], @@ -798,7 +794,9 @@ class _ProfilePageState extends ConsumerState { ), const SizedBox(width: 8), OutlinedButton( - onPressed: isUpdating || _isSavingField ? null : () => _cancelEditing(profile), + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), child: Text(tr('ui.common.cancel')), ), ], diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 89dd0a20..8fbabee1 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; import 'core/i18n/toml_asset_loader.dart'; +import 'core/ui/toast_service.dart'; import 'package:logging/logging.dart'; import 'features/auth/presentation/consent_screen.dart'; import 'i18n.dart'; @@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget { localizationsDelegates: delegates, supportedLocales: supportedLocales, locale: locale, + builder: (context, child) { + return Stack( + children: [if (child != null) child, const ToastViewport()], + ); + }, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base From 2571233c4aace1dc5ba7cc6096a4b2cc82cc8224 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 20 Mar 2026 13:24:26 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Consent=20=ED=99=94=EB=A9=B4=20=EC=95=B1=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.ory.yaml | 2 ++ docker/staging_pull_compose.template.yaml | 2 ++ .../features/auth/presentation/consent_screen.dart | 11 +++++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/compose.ory.yaml b/compose.ory.yaml index 3275a20d..e589acb2 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -211,6 +211,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id adminfront \ + --name "AdminFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ @@ -220,6 +221,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id devfront \ + --name "DevFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 01c6b86f..0430cab4 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -282,6 +282,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id adminfront \ + --name "AdminFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ @@ -291,6 +292,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id devfront \ + --name "DevFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 327b63ff..b8c57597 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -233,10 +233,13 @@ class _ConsentScreenState extends State { } Widget _buildConsentCard(BuildContext context) { - final clientName = - _consentInfo?['client']?['client_name'] ?? - tr('msg.userfront.consent.client_unknown'); - final clientId = _consentInfo?['client']?['client_id'] ?? '-'; + final clientRawName = _consentInfo?['client']?['client_name'] as String?; + final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-'; + final clientName = (clientRawName != null && clientRawName.isNotEmpty) + ? clientRawName + : (clientId != '-' + ? clientId + : tr('msg.userfront.consent.client_unknown')); final clientLogo = _consentInfo?['client']?['logo_uri']; final requestedScopes = (_consentInfo?['requested_scope'] as List?)?.cast() ?? From 397605d5e58ebcce0518a09313f204c4201309a7 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 20 Mar 2026 15:10:40 +0900 Subject: [PATCH 4/8] =?UTF-8?q?uf=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= =?UTF-8?q?=20=EC=95=BD=EA=B4=80=EB=8F=99=EC=9D=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=ED=83=91=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.toml | 6 + locales/ko.toml | 7 +- locales/template.toml | 7 +- .../auth/presentation/signup_screen.dart | 408 +++++++++++++----- 4 files changed, 323 insertions(+), 105 deletions(-) diff --git a/locales/en.toml b/locales/en.toml index 1fd73f87..4a5d6f06 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -680,6 +680,11 @@ privacy_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ tos_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론 소프트웨어 이용약관\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1장 총칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 약관은 바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (용어의 정의)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (약관의 효력 및 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (약관 외 준칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2장 서비스 이용계약\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (이용계약의 성립)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (이용계약의 유보와 거절)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (계약사항의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3장 개인정보 보호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보 보호의 원칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보처리방침 준수)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4장 서비스 제공 및 이용\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (서비스 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (서비스의 변경 및 중단)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5장 정보 제공 및 광고\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (정보 제공 및 광고)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6장 게시물 관리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (게시물의 관리)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제15조 (게시물의 저작권)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7장 계약 해지 및 이용 제한\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제16조 (계약 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제17조 (이용 제한)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8장 손해 배상 및 면책 조항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제18조 (손해 배상)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제19조 (면책 조항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9장 유료 서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n20조 (유료 서비스의 이용)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제21조(환불 정책)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제22조 (유료 서비스의 중지 및 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10장 양도 금지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제23조 (양도 금지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11장 관할 법원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제24조 (분쟁 해결)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제25조 (관할 법원)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" [msg.userfront.signup.agreement] +all_hint = "Agree to both required documents to continue to the next step." +description = "Review the service terms and privacy collection notice, then agree to continue." +privacy_summary = "Review what personal data is collected, why it is used, and how it is retained." +progress = "{{count}} of {{total}} required agreements completed" +tos_summary = "Review the service terms, usage conditions, and responsibilities." title = "Please review and agree to the terms to continue." [msg.userfront.signup.auth] @@ -1721,6 +1726,7 @@ title = "Sign up" [ui.userfront.signup.agreement] all = "Agree to all" privacy_title = "Privacy Policy (Required)" +required = "Required" tos_title = "Terms of Service (Required)" [ui.userfront.signup.auth] diff --git a/locales/ko.toml b/locales/ko.toml index dce14c55..0ae50573 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -676,6 +676,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..." tos_full = "서비스 이용약관 전문..." [msg.userfront.signup.agreement] +all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다." +description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요." +privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다." +progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료" +tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다." title = "서비스 이용을 위해\n약관에 동의해주세요" [msg.userfront.signup.auth] @@ -1709,6 +1714,7 @@ title = "회원가입" [ui.userfront.signup.agreement] all = "모두 동의합니다" privacy_title = "개인정보 수집 및 이용 동의 (필수)" +required = "필수" tos_title = "바론 소프트웨어 이용약관 (필수)" [ui.userfront.signup.auth] @@ -1742,4 +1748,3 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" - diff --git a/locales/template.toml b/locales/template.toml index a74e4a7d..5337c97f 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -676,6 +676,11 @@ privacy_full = "" tos_full = "" [msg.userfront.signup.agreement] +all_hint = "" +description = "" +privacy_summary = "" +progress = "" +tos_summary = "" title = "" [msg.userfront.signup.auth] @@ -1709,6 +1714,7 @@ title = "" [ui.userfront.signup.agreement] all = "" privacy_title = "" +required = "" tos_title = "" [ui.userfront.signup.auth] @@ -1742,4 +1748,3 @@ verify = "" [ui.userfront.signup.success] action = "" - diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 708e4d5e..400106c0 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -14,6 +14,13 @@ class SignupScreen extends StatefulWidget { } class _SignupScreenState extends State { + static const _signupInk = Color(0xFF111827); + static const _signupBorder = Color(0xFFE5E7EB); + static const _signupSurface = Color(0xFFF8FAFC); + static const _signupMuted = Color(0xFF6B7280); + static const _agreementDesktopBreakpoint = 960.0; + static const _agreementCardMaxWidth = 880.0; + final _formKey = GlobalKey(); int _currentStep = 1; @@ -417,105 +424,305 @@ class _SignupScreenState extends State { } Widget _buildStepAgreement() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.agreement.title'), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - height: 1.3, - ), - ), - const SizedBox(height: 24), - // 모두 동의 버튼 - Container( - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey[200]!), - ), - child: CheckboxListTile( - title: Text( - tr('ui.userfront.signup.agreement.all'), - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.agreement.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + Text( + tr('msg.userfront.signup.agreement.description'), + style: TextStyle( + fontSize: isDesktop ? 15 : 14, + height: 1.6, + color: _signupMuted, + ), + ), + SizedBox(height: isDesktop ? 28 : 24), + _buildAgreementSummaryCard(isDesktop: isDesktop), + const SizedBox(height: 18), + _agreementSection( + title: tr('ui.userfront.signup.agreement.tos_title'), + summary: tr('msg.userfront.signup.agreement.tos_summary'), + content: _tosText, + value: _termsAccepted, + isDesktop: isDesktop, + onChanged: (val) => + setState(() => _termsAccepted = val ?? false), + ), + const SizedBox(height: 18), + _agreementSection( + title: tr('ui.userfront.signup.agreement.privacy_title'), + summary: tr( + 'msg.userfront.signup.agreement.privacy_summary', + ), + content: _privacyText, + value: _privacyAccepted, + isDesktop: isDesktop, + onChanged: (val) => + setState(() => _privacyAccepted = val ?? false), + ), + ], + ), + ), ), - value: _termsAccepted && _privacyAccepted, - onChanged: (val) { - setState(() { - _termsAccepted = val!; - _privacyAccepted = val; - }); - }, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Colors.black, ), + ); + }, + ); + } + + Widget _buildAgreementSummaryCard({required bool isDesktop}) { + final acceptedCount = [ + _termsAccepted, + _privacyAccepted, + ].where((accepted) => accepted).length; + + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CheckboxListTile( + title: Text( + tr('ui.userfront.signup.agreement.all'), + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + tr('msg.userfront.signup.agreement.all_hint'), + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ), + value: _termsAccepted && _privacyAccepted, + onChanged: (val) { + setState(() { + final next = val ?? false; + _termsAccepted = next; + _privacyAccepted = next; + }); + }, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: _signupInk, + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.signup.agreement.progress', + params: {'count': '$acceptedCount', 'total': '2'}, + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _signupMuted, + ), + ), + ], ), - const SizedBox(height: 16), - _agreementSection( - title: tr('ui.userfront.signup.agreement.tos_title'), - content: _tosText, - value: _termsAccepted, - onChanged: (val) => setState(() => _termsAccepted = val!), - ), - const SizedBox(height: 16), - _agreementSection( - title: tr('ui.userfront.signup.agreement.privacy_title'), - content: _privacyText, - value: _privacyAccepted, - onChanged: (val) => setState(() => _privacyAccepted = val!), - ), - ], + ), ); } Widget _agreementSection({ required String title, + required String summary, required String content, required bool value, + required bool isDesktop, required ValueChanged onChanged, }) { - return Column( - children: [ - CheckboxListTile( - title: Text( - title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - value: value, - onChanged: onChanged, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - activeColor: Colors.black, - ), - Container( - height: 120, - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(4), - ), - child: SingleChildScrollView( - child: Text( - content, - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - height: 1.5, + final contentHeight = isDesktop ? 260.0 : 148.0; + + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: CheckboxListTile( + title: Text( + title, + style: TextStyle( + fontSize: isDesktop ? 16 : 14, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + summary, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ), + value: value, + onChanged: onChanged, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: _signupInk, + ), + ), + const SizedBox(width: 12), + _buildRequiredBadge(), + ], + ), + const SizedBox(height: 12), + Container( + height: contentHeight, + width: double.infinity, + padding: EdgeInsets.all(isDesktop ? 18 : 14), + decoration: BoxDecoration( + color: _signupSurface, + border: Border.all(color: _signupBorder), + borderRadius: BorderRadius.circular(14), + ), + child: SingleChildScrollView( + child: SelectableText( + content, + style: TextStyle( + fontSize: isDesktop ? 13 : 12, + color: _signupMuted, + height: 1.6, + ), + ), ), ), - ), + ], ), - ], + ), ); } - static String get _tosText => tr( - 'msg.userfront.signup.tos_full', - fallback: """ + Widget _buildRequiredBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Text( + tr('ui.userfront.signup.agreement.required'), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF3730A3), + ), + ), + ); + } + + Widget _buildStepBody() { + final stepChild = _currentStep == 1 + ? _buildStepAgreement() + : (_currentStep == 2 + ? _buildStepAuth() + : (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())); + + return LayoutBuilder( + builder: (context, constraints) { + final isAgreementStep = _currentStep == 1; + final horizontalPadding = + isAgreementStep && + constraints.maxWidth >= _agreementDesktopBreakpoint + ? 40.0 + : 24.0; + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + 24, + horizontalPadding, + 32, + ), + child: Form(key: _formKey, child: stepChild), + ); + }, + ); + } + + static String _resolveAgreementText( + String key, { + required String fallback, + required Set placeholders, + }) { + final localized = tr(key, fallback: '').trim(); + if (localized.isEmpty || placeholders.contains(localized)) { + return fallback; + } + return localized; + } + + static String get _tosText { + const fallback = """ 바론 소프트웨어 이용약관 제1장 총칙 @@ -585,12 +792,16 @@ class _SignupScreenState extends State { 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 부칙 본 약관은 2024년 10월 1일부터 시행됩니다. -""", - ); +"""; + return _resolveAgreementText( + 'msg.userfront.signup.tos_full', + fallback: fallback, + placeholders: {'서비스 이용약관 전문...', 'Tos Full'}, + ); + } - static String get _privacyText => tr( - 'msg.userfront.signup.privacy_full', - fallback: """ + static String get _privacyText { + const fallback = """ 개인정보 수집 및 이용 동의 바론서비스 개인정보처리방침 @@ -698,8 +909,13 @@ class _SignupScreenState extends State { 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 제8조 (기타) 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. -""", - ); +"""; + return _resolveAgreementText( + 'msg.userfront.signup.privacy_full', + fallback: fallback, + placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'}, + ); + } Widget _buildStepAuth() { return Column( @@ -1233,21 +1449,7 @@ class _SignupScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20), child: _buildStepIndicator(), ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: _currentStep == 1 - ? _buildStepAgreement() - : (_currentStep == 2 - ? _buildStepAuth() - : (_currentStep == 3 - ? _buildStepInfo() - : _buildStepPassword())), - ), - ), - ), + Expanded(child: _buildStepBody()), Padding( padding: const EdgeInsets.all(24), child: Row( From 3269bbb981b24e147eb0ba8a347a295bd289dddc Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 20 Mar 2026 16:27:15 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=95=8C=EB=A6=BC=EC=B0=BD=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/lib/features/auth/presentation/signup_screen.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 400106c0..eefa7a6f 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -353,7 +353,10 @@ class _SignupScreenState extends State { content: Text(tr('msg.userfront.signup.success.body')), actions: [ TextButton( - onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)), + onPressed: () { + Navigator.of(context).pop(); + context.go(buildLocalizedSigninPath(Uri.base)); + }, child: Text(tr('ui.userfront.signup.success.action')), ), ], From 101baa68f6dd0a9db27ba369b7d79f3b1cd82a63 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 23 Mar 2026 14:16:09 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EB=B3=84=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=83=91=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=ED=86=A0=EA=B8=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 1475 ++++++++++++----- 1 file changed, 1062 insertions(+), 413 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index eefa7a6f..e4f5a302 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -18,8 +19,12 @@ class _SignupScreenState extends State { static const _signupBorder = Color(0xFFE5E7EB); static const _signupSurface = Color(0xFFF8FAFC); static const _signupMuted = Color(0xFF6B7280); + static const _signupDone = Color(0xFF0F766E); + static const _signupDoneSurface = Color(0xFFECFDF5); static const _agreementDesktopBreakpoint = 960.0; static const _agreementCardMaxWidth = 880.0; + static const _stepIndicatorDesktopBreakpoint = 720.0; + static const _stepIndicatorMaxWidth = 880.0; final _formKey = GlobalKey(); int _currentStep = 1; @@ -45,6 +50,8 @@ class _SignupScreenState extends State { bool _isLoading = false; Map? _policy; bool _isPolicyLoading = false; + bool _isPasswordObscured = true; + bool _isConfirmPasswordObscured = true; // Inline Errors String? _emailError; @@ -367,63 +374,153 @@ class _SignupScreenState extends State { // --- UI Components --- Widget _buildStepIndicator() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - _stepCircle(1, tr('ui.userfront.signup.steps.agreement')), - _stepLine(1), - _stepCircle(2, tr('ui.userfront.signup.steps.verify')), - _stepLine(2), - _stepCircle(3, tr('ui.userfront.signup.steps.profile')), - _stepLine(3), - _stepCircle(4, tr('ui.userfront.signup.steps.password')), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = + constraints.maxWidth >= _stepIndicatorDesktopBreakpoint; + final indicatorWidth = isDesktop + ? math.min(constraints.maxWidth, _stepIndicatorMaxWidth) + : constraints.maxWidth; + + return Padding( + padding: EdgeInsets.symmetric(vertical: isDesktop ? 24 : 20), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: indicatorWidth, + child: Row( + children: [ + _stepCircle( + 1, + tr('ui.userfront.signup.steps.agreement'), + isDesktop: isDesktop, + ), + _stepLine(1, isDesktop: isDesktop), + _stepCircle( + 2, + tr('ui.userfront.signup.steps.verify'), + isDesktop: isDesktop, + ), + _stepLine(2, isDesktop: isDesktop), + _stepCircle( + 3, + tr('ui.userfront.signup.steps.profile'), + isDesktop: isDesktop, + ), + _stepLine(3, isDesktop: isDesktop), + _stepCircle( + 4, + tr('ui.userfront.signup.steps.password'), + isDesktop: isDesktop, + ), + ], + ), + ), + ), + ); + }, ); } - Widget _stepCircle(int step, String label) { - bool isDone = _currentStep > step; - bool isCurrent = _currentStep == step; - return Column( + Widget _stepCircle(int step, String label, {required bool isDesktop}) { + final isDone = _currentStep > step; + final isCurrent = _currentStep == step; + final circleRadius = isDesktop ? 17.0 : 12.0; + final labelStyle = TextStyle( + fontSize: isDesktop ? 12 : 9, + color: isCurrent ? _signupInk : (isDone ? _signupDone : _signupMuted), + fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500, + height: 1.2, + ); + + final stepWidget = Column( + mainAxisSize: MainAxisSize.min, children: [ - CircleAvatar( - radius: 12, - backgroundColor: isDone - ? Colors.green - : (isCurrent ? Colors.black : Colors.grey[300]), - child: isDone - ? const Icon(Icons.check, size: 14, color: Colors.white) - : Text( - '$step', - style: TextStyle( - color: isCurrent ? Colors.white : Colors.black54, - fontSize: 10, + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: circleRadius * 2, + height: circleRadius * 2, + decoration: BoxDecoration( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupSurface), + shape: BoxShape.circle, + border: Border.all( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupBorder), + width: isCurrent ? 1.5 : 1, + ), + boxShadow: isDesktop && (isCurrent || isDone) + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 14, + offset: Offset(0, 8), + ), + ] + : const [], + ), + child: Center( + child: isDone + ? Icon( + Icons.check, + size: isDesktop ? 18 : 14, + color: Colors.white, + ) + : Text( + '$step', + style: TextStyle( + color: isCurrent ? Colors.white : _signupMuted, + fontSize: isDesktop ? 13 : 10, + fontWeight: FontWeight.w700, + ), ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 9, - color: isCurrent ? Colors.black : Colors.grey, - fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), ), + SizedBox(height: isDesktop ? 8 : 4), + Text(label, textAlign: TextAlign.center, style: labelStyle), ], ); + + if (!isDesktop) { + return stepWidget; + } + + return SizedBox(width: 96, child: stepWidget); } - Widget _stepLine(int afterStep) { - return Expanded( - child: Container( - margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2), - height: 1.5, - color: _currentStep > afterStep ? Colors.green : Colors.grey[300], + Widget _stepLine(int afterStep, {required bool isDesktop}) { + final line = Container( + margin: EdgeInsets.only( + bottom: isDesktop ? 26 : 16, + left: isDesktop ? 10 : 2, + right: isDesktop ? 10 : 2, + ), + height: isDesktop ? 2 : 1.5, + decoration: BoxDecoration( + color: _currentStep > afterStep ? _signupDoneSurface : _signupBorder, + borderRadius: BorderRadius.circular(999), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _currentStep > afterStep ? 1 : 0, + child: Container( + decoration: BoxDecoration( + color: _signupDone, + borderRadius: BorderRadius.circular(999), + ), + ), + ), ), ); + + if (isDesktop) { + return Expanded(child: line); + } + + return Expanded(child: line); } Widget _buildStepAgreement() { @@ -921,302 +1018,695 @@ class _SignupScreenState extends State { } Widget _buildStepAuth() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.auth.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // 가족사 이메일 안내 문구 - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(6), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.auth.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildAuthNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.alternate_email_rounded, + title: tr('ui.userfront.signup.auth.email.title'), + label: tr('ui.userfront.signup.auth.email.label'), + hintText: 'example@hanmaceng.co.kr', + controller: _emailController, + errorText: _emailError, + readOnly: _isEmailVerified, + buttonLabel: _emailSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isEmailVerified && !_isLoading, + onRequestCode: _sendEmailCode, + verificationController: _emailCodeController, + verificationVisible: + _emailSeconds > 0 && !_isEmailVerified, + verificationCountdown: _formatTime(_emailSeconds), + verified: _isEmailVerified, + verifiedText: tr('msg.userfront.signup.email.verified'), + keyboardType: TextInputType.emailAddress, + onChanged: _checkEmailAffiliation, + onVerificationChanged: (val) { + if (val.length == 6) _verifyEmailCode(); + }, + ), + const SizedBox(height: 18), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.phone_iphone_rounded, + title: tr('ui.userfront.signup.phone.title'), + label: tr('ui.userfront.signup.phone.label'), + controller: _phoneController, + errorText: _phoneError, + readOnly: _isPhoneVerified, + buttonLabel: _phoneSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isPhoneVerified && !_isLoading, + onRequestCode: _sendPhoneCode, + verificationController: _phoneCodeController, + verificationVisible: + _phoneSeconds > 0 && !_isPhoneVerified, + verificationCountdown: _formatTime(_phoneSeconds), + verified: _isPhoneVerified, + verifiedText: tr('msg.userfront.signup.phone.verified'), + keyboardType: TextInputType.phone, + onVerificationChanged: (val) { + if (val.length == 6) _verifyPhoneCode(); + }, + ), + ], + ), + ), + ), ), - child: Row( - children: [ - const Icon(Icons.info_outline, size: 16, color: Colors.blue), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.signup.auth.affiliate_notice'), - style: const TextStyle( - fontSize: 12, - color: Colors.blue, - fontWeight: FontWeight.w500, + ); + }, + ); + } + + Widget _buildAuthNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.info_outline, + size: 18, + color: Color(0xFF1D4ED8), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + tr('msg.userfront.signup.auth.affiliate_notice'), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildVerificationCard({ + required bool isDesktop, + required IconData icon, + required String title, + required String label, + required TextEditingController controller, + required String buttonLabel, + required bool buttonEnabled, + required Future Function() onRequestCode, + required TextEditingController verificationController, + required bool verificationVisible, + required String verificationCountdown, + required bool verified, + required String verifiedText, + String? hintText, + String? errorText, + bool readOnly = false, + TextInputType? keyboardType, + ValueChanged? onChanged, + ValueChanged? onVerificationChanged, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: isDesktop ? 40 : 36, + height: isDesktop ? 40 : 36, + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _signupBorder), + ), + child: Icon(icon, size: 18, color: _signupInk), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: isDesktop ? 18 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), ), ), - ), - ], - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.auth.email.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _emailController, - onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.email.label'), - border: const OutlineInputBorder(), - errorText: _emailError, - hintText: 'example@hanmaceng.co.kr', - ), - readOnly: _isEmailVerified, - ), + if (verified) _buildVerifiedBadge(verifiedText), + ], ), - const SizedBox(width: 8), - SizedBox( - height: 55, - child: ElevatedButton( - onPressed: (_isEmailVerified || _isLoading) - ? null - : _sendEmailCode, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[100], - foregroundColor: Colors.black, - elevation: 0, + SizedBox(height: isDesktop ? 20 : 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + errorText: errorText, + hintText: hintText, + ), + readOnly: readOnly, + keyboardType: keyboardType, + ), + ), ), - child: Text( - _emailSeconds > 0 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), + const SizedBox(width: 10), + SizedBox( + height: 52, + width: isDesktop ? 108 : null, + child: FilledButton( + onPressed: buttonEnabled ? onRequestCode : null, + style: FilledButton.styleFrom( + backgroundColor: _signupInk, + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFFE5E7EB), + disabledForegroundColor: const Color(0xFF9CA3AF), + ), + child: Text(buttonLabel), + ), ), + ], + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (verificationVisible) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: verificationController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.auth.code_label', + ), + suffixText: verificationCountdown, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onChanged: onVerificationChanged, + ), + ), + ), + const SizedBox(width: 10), + SizedBox(width: isDesktop ? 108 : 0), + ], + ), + ], + if (verified) ...[ + const SizedBox(height: 12), + _buildVerificationStatus(verifiedText), + ], + ], ), ), ], ), - if (_emailSeconds > 0 && !_isEmailVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _emailCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_emailSeconds), - border: const OutlineInputBorder(), + ), + ); + } + + Widget _buildVerifiedBadge(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: _signupDone, + ), + ), + ); + } + + Widget _buildVerificationStatus(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, size: 18, color: _signupDone), + const SizedBox(width: 8), + Expanded( + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + color: _signupDone, + fontSize: 13, + fontWeight: FontWeight.w700, + ), ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyEmailCode(); - }, ), ], - if (_isEmailVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.email.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.phone.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _phoneController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.phone.label'), - border: const OutlineInputBorder(), - errorText: _phoneError, - ), - readOnly: _isPhoneVerified, - keyboardType: TextInputType.phone, - ), - ), - const SizedBox(width: 8), - SizedBox( - height: 55, - child: ElevatedButton( - onPressed: (_isPhoneVerified || _isLoading) - ? null - : _sendPhoneCode, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[100], - foregroundColor: Colors.black, - elevation: 0, - ), - child: Text( - _phoneSeconds > 0 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), - ), - ), - ), - ], - ), - if (_phoneSeconds > 0 && !_isPhoneVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _phoneCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_phoneSeconds), - border: const OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyPhoneCode(); - }, - ), - ], - if (_isPhoneVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.phone.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ], + ), ); } Widget _buildStepInfo() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.profile.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 24), - TextFormField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.name'), - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - // 소속 유형 선택 (가족사 메일일 경우 비활성화) - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.affiliation_type'), - border: const OutlineInputBorder(), - helperText: _isAffiliateEmail - ? tr('msg.userfront.signup.profile.affiliate_hint') - : null, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text(tr('domain.affiliation.general')), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text(tr('domain.affiliation.affiliate')), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.profile.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildProfileInfoNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.name'), + description: '기본 정보', + isDesktop: isDesktop, + child: TextFormField( + controller: _nameController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.profile.name'), + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.affiliation_type'), + description: _isAffiliateEmail + ? tr('msg.userfront.signup.profile.affiliate_hint') + : '소속 유형과 회사 정보를 입력합니다.', + isDesktop: isDesktop, + trailing: _isAffiliateEmail + ? _buildAutoDetectedBadge() + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_affiliationType), + initialValue: _affiliationType, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', + ), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text( + tr('domain.affiliation.general'), + ), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text( + tr('domain.affiliation.affiliate'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + }); + }, + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_affiliationType == 'AFFILIATE') ...[ + const SizedBox(height: 14), + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', + ), + border: + const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'HANMAC', + child: Text( + tr('domain.company.hanmac'), + ), + ), + DropdownMenuItem( + value: 'SAMAN', + child: Text( + tr('domain.company.saman'), + ), + ), + DropdownMenuItem( + value: 'PTC', + child: Text( + tr( + 'domain.company.ptc', + fallback: 'PTC', + ), + ), + ), + DropdownMenuItem( + value: 'JANGHEON', + child: Text( + tr('domain.company.jangheon'), + ), + ), + DropdownMenuItem( + value: 'BARON', + child: Text( + tr('domain.company.baron'), + ), + ), + DropdownMenuItem( + value: 'HALLA', + child: Text( + tr('domain.company.halla'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) => setState( + () => _companyCode = val, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department') + : tr('ui.userfront.signup.profile.department_optional'), + description: _affiliationType == 'AFFILIATE' + ? '가족사 사용자는 부서명을 입력해주세요.' + : '선택 입력 항목입니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _deptController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department') + : tr( + 'ui.userfront.signup.profile.department_optional', + ), + border: const OutlineInputBorder(), + ), + ), + ), + ], ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - }); - }, + ), ), ), - ), - const SizedBox(height: 16), - // 가족사 선택 (가족사 메일일 경우 비활성화) - if (_affiliationType == 'AFFILIATE') ...[ - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.company'), - border: const OutlineInputBorder(), + ); + }, + ); + } + + Widget _buildProfileInfoNoticeCard({required bool isDesktop}) { + final description = _isAffiliateEmail + ? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.' + : '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; + + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF4338CA), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + description, + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: _signupInk, + fontWeight: FontWeight.w600, ), - items: [ - DropdownMenuItem( - value: 'HANMAC', - child: Text(tr('domain.company.hanmac')), - ), - DropdownMenuItem( - value: 'SAMAN', - child: Text(tr('domain.company.saman')), - ), - DropdownMenuItem( - value: 'PTC', - child: Text(tr('domain.company.ptc', fallback: 'PTC')), - ), - DropdownMenuItem( - value: 'JANGHEON', - child: Text(tr('domain.company.jangheon')), - ), - DropdownMenuItem( - value: 'BARON', - child: Text(tr('domain.company.baron')), - ), - DropdownMenuItem( - value: 'HALLA', - child: Text(tr('domain.company.halla')), + ), + ), + ], + ), + ), + ); + } + + Widget _buildProfileFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + Widget? trailing, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ], ), + ), + if (trailing != null) ...[ + const SizedBox(width: 12), + trailing, ], - onChanged: _isAffiliateEmail - ? null - : (val) => setState(() => _companyCode = val), - ), + ], ), - ), - const SizedBox(height: 16), - ], - TextFormField( - controller: _deptController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _affiliationType == 'AFFILIATE' - ? tr('ui.userfront.signup.profile.department') - : tr('ui.userfront.signup.profile.department_optional'), - border: const OutlineInputBorder(), - ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - ], + ), + ); + } + + Widget _buildAutoDetectedBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: const Text( + '자동 선택', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF4338CA), + ), + ), ); } @@ -1265,7 +1755,7 @@ class _SignupScreenState extends State { } Widget _buildStepPassword() { - String p = _passwordController.text; + final p = _passwordController.text; // Default Policy Fallback final minLength = (_policy?['minLength'] as int?) ?? 12; @@ -1275,120 +1765,279 @@ class _SignupScreenState extends State { final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; - bool hasLength = p.length >= minLength; - bool hasUpper = p.contains(RegExp(r'[A-Z]')); - bool hasLower = p.contains(RegExp(r'[a-z]')); - bool hasDigit = p.contains(RegExp(r'[0-9]')); - bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + final hasLength = p.length >= minLength; + final hasUpper = p.contains(RegExp(r'[A-Z]')); + final hasLower = p.contains(RegExp(r'[a-z]')); + final hasDigit = p.contains(RegExp(r'[0-9]')); + final hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); int typeCount = 0; if (hasUpper) typeCount++; if (hasLower) typeCount++; if (hasDigit) typeCount++; if (hasSpecial) typeCount++; - bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final passwordChecks = [ + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_length', + params: {'count': minLength.toString()}, + ), + hasLength, + ), + if (minTypes > 0) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_types', + params: {'count': minTypes.toString()}, + ), + hasTypeCount, + ), + if (requiresUpper) + _cryptoCheck(tr('msg.userfront.signup.password.rule.uppercase'), hasUpper), + if (requiresLower) + _cryptoCheck(tr('msg.userfront.signup.password.rule.lowercase'), hasLower), + if (requiresNumber) + _cryptoCheck(tr('msg.userfront.signup.password.rule.number'), hasDigit), + if (requiresSymbol) + _cryptoCheck(tr('msg.userfront.signup.password.rule.symbol'), hasSpecial), + ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.password.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // 비밀번호 정책 안내 박스 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon(Icons.security, size: 18, color: Colors.blue), - const SizedBox(width: 10), - Expanded( - child: Text( - _buildPolicyDescription(), - style: TextStyle( - fontSize: 12, - color: Colors.blue[800], - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - TextFormField( - controller: _passwordController, - obscureText: true, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.label'), - border: const OutlineInputBorder(), - errorText: _passwordError, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 10, - children: [ - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_length', - params: {'count': minLength.toString()}, - ), - hasLength, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, ), - if (minTypes > 0) - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_types', - params: {'count': minTypes.toString()}, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.password.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildPasswordPolicyNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.label'), + description: '보안 정책에 맞는 비밀번호를 입력합니다.', + isDesktop: isDesktop, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _passwordController, + obscureText: _isPasswordObscured, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.password.label'), + border: const OutlineInputBorder(), + errorText: _passwordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isPasswordObscured = + !_isPasswordObscured; + }); + }, + icon: Icon( + _isPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + const SizedBox(height: 14), + _buildPasswordChecksCard( + checks: passwordChecks, + isDesktop: isDesktop, + ), + ], + ), + ), + const SizedBox(height: 18), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.confirm_label'), + description: '입력한 비밀번호를 한 번 더 확인합니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _confirmPasswordController, + obscureText: _isConfirmPasswordObscured, + onChanged: (val) { + setState(() { + _confirmPasswordError = (val != + _passwordController.text) + ? tr('msg.userfront.signup.password.mismatch') + : null; + }); + }, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.password.confirm_label', + ), + border: const OutlineInputBorder(), + errorText: _confirmPasswordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isConfirmPasswordObscured = + !_isConfirmPasswordObscured; + }); + }, + icon: Icon( + _isConfirmPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + ), + ], ), - hasTypeCount, ), - if (requiresUpper) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.uppercase'), - hasUpper, + ), + ), + ); + }, + ); + } + + Widget _buildPasswordPolicyNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), ), - if (requiresLower) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.lowercase'), - hasLower, + child: const Icon( + Icons.security_rounded, + size: 18, + color: Color(0xFF1D4ED8), ), - if (requiresNumber) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.number'), - hasDigit, - ), - if (requiresSymbol) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.symbol'), - hasSpecial, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _buildPolicyDescription(), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), ), + ), ], ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: true, - onChanged: (val) { - setState(() { - _confirmPasswordError = (val != _passwordController.text) - ? tr('msg.userfront.signup.password.mismatch') - : null; - }); - }, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.confirm_label'), - border: const OutlineInputBorder(), - errorText: _confirmPasswordError, - ), + ), + ); + } + + Widget _buildPasswordFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - ], + ), + ); + } + + Widget _buildPasswordChecksCard({ + required List checks, + required bool isDesktop, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 16 : 14), + child: Wrap( + spacing: 12, + runSpacing: 10, + children: checks, + ), + ), ); } From 3c54c468988f913892608fe3c872807baf40f6e9 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 23 Mar 2026 14:43:50 +0900 Subject: [PATCH 7/8] =?UTF-8?q?adminfront/devfront=20=EC=95=B1=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B8=B0=EB=B3=B8=20URL=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 8 ++-- .gitea/workflows/staging_code_pull.yml | 2 + backend/internal/handler/auth_handler.go | 49 +++++++++++++++++------- scripts/auth_config.sh | 17 ++++++-- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/.env.sample b/.env.sample index 1dce7295..9d4882d9 100644 --- a/.env.sample +++ b/.env.sample @@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445 # Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc -# OIDC 클라이언트 callback (콤마 구분) -ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback -DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback - # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. KRATOS_ALLOWED_RETURN_URLS_EXTRA=[] @@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf CSRF_COOKIE_SECRET=localcsrf123 # AdminFront OIDC 설정 +ADMINFRONT_URL=http://localhost:5173 ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback # DevFront OIDC 설정 VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc -DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback \ No newline at end of file +DEVFRONT_URL=http://localhost:5174 +DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index fab15b99..1e0f7d6c 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -120,6 +120,8 @@ jobs: # Frontend OIDC configs for Staging VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc + ADMINFRONT_URL=http://172.16.10.176:5173 + DEVFRONT_URL=http://172.16.10.176:5174 ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index b946b94b..3fabec6e 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { name = clientID } - // ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback) - clientURL := strings.TrimSpace(client.ClientURI) - if clientURL == "" && len(client.RedirectURIs) > 0 { - if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { - clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) - } - } + clientURL := resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) lastAuth := time.Time{} if session.AuthenticatedAt != nil { @@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { name = client.ClientID } - clientURL := strings.TrimSpace(client.ClientURI) - if clientURL == "" && len(client.RedirectURIs) > 0 { - if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { - clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) - } - } + clientURL := resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ @@ -5423,6 +5420,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string { return "" } +func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string { + switch strings.TrimSpace(clientID) { + case "adminfront": + if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" { + return value + } + case "devfront": + if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" { + return value + } + } + + clientURL := strings.TrimSpace(clientURI) + if clientURL != "" { + return clientURL + } + + if len(redirectURIs) > 0 { + if parsed, err := url.Parse(redirectURIs[0]); err == nil { + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) + } + } + + return "" +} + func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index 0a9f3bee..4a4d4bae 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}" OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}" HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" -ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" -DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}" +ADMINFRONT_URL="${ADMINFRONT_URL:-http://172.16.10.176:5173}" +DEVFRONT_URL="${DEVFRONT_URL:-http://172.16.10.176:5174}" +ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}" +DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" declare -a WARNINGS=() @@ -382,12 +384,21 @@ run_validation() { validate_dotenv_line_safety "HYDRA_PUBLIC_URL" validate_dotenv_line_safety "KRATOS_BROWSER_URL" validate_dotenv_line_safety "KRATOS_UI_URL" + validate_dotenv_line_safety "ADMINFRONT_URL" + validate_dotenv_line_safety "DEVFRONT_URL" validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" + if [[ -n "$ADMINFRONT_URL" ]]; then + validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL" + fi + if [[ -n "$DEVFRONT_URL" ]]; then + validate_urls "DEVFRONT_URL" "$DEVFRONT_URL" + fi + collect_values validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" - validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}" + validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}" validate_gateway_mapping build_allowed_return_urls } From e98ab39dfe6cc22b7e69165398f5001928590044 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 23 Mar 2026 15:36:00 +0900 Subject: [PATCH 8/8] =?UTF-8?q?code=20check=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 14 +- adminfront/playwright.config.ts | 6 +- devfront/playwright.config.ts | 6 +- userfront-e2e/playwright.config.ts | 5 +- .../tests/profile-department.spec.ts | 15 +- userfront/assets/translations/en.toml | 6 + userfront/assets/translations/ko.toml | 7 +- userfront/assets/translations/template.toml | 7 +- .../auth/presentation/consent_screen.dart | 4 +- .../auth/presentation/signup_screen.dart | 44 +++--- .../test/profile_page_edit_flow_test.dart | 145 ++++++++++-------- 11 files changed, 155 insertions(+), 104 deletions(-) diff --git a/Makefile b/Makefile index 0eea204a..8466a2b2 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,10 @@ endif .PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests +CODE_CHECK_TEST_JOBS ?= 1 +PLAYWRIGHT_WORKERS ?= 1 +FLUTTER_TEST_CONCURRENCY ?= 1 + code-check: code-check-lint code-check-test-jobs @echo "code-check complete." @@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c code-check-test-jobs: @echo "==> run CI-equivalent test jobs (parallel)" - @$(MAKE) --no-print-directory -j5 --output-sync=target \ + @$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \ code-check-backend-tests \ code-check-userfront-tests \ code-check-userfront-e2e-tests \ @@ -203,11 +207,11 @@ code-check-userfront-tests: rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \ fi; \ cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ - cd "$$tmp_dir/userfront" && flutter test + cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY) code-check-adminfront-tests: @echo "==> adminfront tests" - ./scripts/run_adminfront_ci_tests.sh adminfront-tests + PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests code-check-devfront-tests: @echo "==> devfront tests" @@ -219,7 +223,7 @@ code-check-devfront-tests: (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ fi; \ if [ $$status -eq 0 ]; then \ - (cd devfront && npm test) || status=$$?; \ + (cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \ fi; \ [ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \ [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ @@ -267,7 +271,7 @@ code-check-userfront-e2e-tests: if [ $$status -eq 0 ]; then \ port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \ echo "==> userfront-e2e using PORT=$$port"; \ - (cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \ + (cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \ fi; \ [ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \ [ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \ diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index f4bfe8e1..2acbace2 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; + /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -24,7 +28,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index 9606c1ce..b52394df 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; + /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -20,7 +24,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/userfront-e2e/playwright.config.ts b/userfront-e2e/playwright.config.ts index d7e33047..fcc38967 100644 --- a/userfront-e2e/playwright.config.ts +++ b/userfront-e2e/playwright.config.ts @@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10); const defaultBaseUrl = `http://127.0.0.1:${port}`; const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const reuseExistingServer = !process.env.CI; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; export default defineConfig({ testDir: './tests', fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html', use: { baseURL, diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts index 4d8b468d..e22db24d 100644 --- a/userfront-e2e/tests/profile-department.spec.ts +++ b/userfront-e2e/tests/profile-department.spec.ts @@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise { await page.waitForTimeout(250); } +async function submitDepartmentEditor(page: Page): Promise { + await page.keyboard.press('Enter'); + await page.waitForTimeout(250); +} + async function mockProfileApis(page: Page, state: ProfileState): Promise { await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); @@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => { await page.unroute('**/api/v1/**'); }); - test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ + test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ page, }) => { const state: ProfileState = { @@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); expect(state.putBodies[0]?.department).toBe('QA-Updated'); @@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => { expect(state.department).toBe('QA'); }); - test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => { + test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => { const state: ProfileState = { department: 'QA', getMeCount: 0, @@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); await page.reload(); @@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(2); expect(state.putBodies[0]?.department).toBe('QA-1'); diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index c3525849..538eeb5b 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -280,6 +280,11 @@ privacy_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ tos_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론 소프트웨어 이용약관\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1장 총칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 약관은 바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (용어의 정의)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (약관의 효력 및 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (약관 외 준칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2장 서비스 이용계약\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (이용계약의 성립)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (이용계약의 유보와 거절)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (계약사항의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3장 개인정보 보호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보 보호의 원칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보처리방침 준수)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4장 서비스 제공 및 이용\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (서비스 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (서비스의 변경 및 중단)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5장 정보 제공 및 광고\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (정보 제공 및 광고)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6장 게시물 관리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (게시물의 관리)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제15조 (게시물의 저작권)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7장 계약 해지 및 이용 제한\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제16조 (계약 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제17조 (이용 제한)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8장 손해 배상 및 면책 조항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제18조 (손해 배상)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제19조 (면책 조항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9장 유료 서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n20조 (유료 서비스의 이용)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제21조(환불 정책)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제22조 (유료 서비스의 중지 및 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10장 양도 금지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제23조 (양도 금지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11장 관할 법원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제24조 (분쟁 해결)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제25조 (관할 법원)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" [msg.userfront.signup.agreement] +all_hint = "Agree to both required documents to continue to the next step." +description = "Review the service terms and privacy collection notice, then agree to continue." +privacy_summary = "Review what personal data is collected, why it is used, and how it is retained." +progress = "{count} of {total} required agreements completed" +tos_summary = "Review the service terms, usage conditions, and responsibilities." title = "Please review and agree to the terms to continue." [msg.userfront.signup.auth] @@ -592,6 +597,7 @@ title = "Sign up" [ui.userfront.signup.agreement] all = "Agree to all" privacy_title = "Privacy Policy (Required)" +required = "Required" tos_title = "Terms of Service (Required)" [ui.userfront.signup.auth] diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index ea883439..18d2b303 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..." tos_full = "서비스 이용약관 전문..." [msg.userfront.signup.agreement] +all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다." +description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요." +privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다." +progress = "필수 약관 {total}개 중 {count}개 동의 완료" +tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다." title = "서비스 이용을 위해\n약관에 동의해주세요" [msg.userfront.signup.auth] @@ -583,6 +588,7 @@ title = "회원가입" [ui.userfront.signup.agreement] all = "모두 동의합니다" privacy_title = "개인정보 수집 및 이용 동의 (필수)" +required = "필수" tos_title = "바론 소프트웨어 이용약관 (필수)" [ui.userfront.signup.auth] @@ -616,4 +622,3 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" - diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index c59a8780..44c85800 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -277,6 +277,11 @@ privacy_full = "" tos_full = "" [msg.userfront.signup.agreement] +all_hint = "" +description = "" +privacy_summary = "" +progress = "" +tos_summary = "" title = "" [msg.userfront.signup.auth] @@ -583,6 +588,7 @@ title = "" [ui.userfront.signup.agreement] all = "" privacy_title = "" +required = "" tos_title = "" [ui.userfront.signup.auth] @@ -616,4 +622,3 @@ verify = "" [ui.userfront.signup.success] action = "" - diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index b8c57597..0d3acd05 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -238,8 +238,8 @@ class _ConsentScreenState extends State { final clientName = (clientRawName != null && clientRawName.isNotEmpty) ? clientRawName : (clientId != '-' - ? clientId - : tr('msg.userfront.consent.client_unknown')); + ? clientId + : tr('msg.userfront.consent.client_unknown')); final clientLogo = _consentInfo?['client']?['logo_uri']; final requestedScopes = (_consentInfo?['requested_scope'] as List?)?.cast() ?? diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index e4f5a302..715c993d 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1494,8 +1494,7 @@ class _SignupScreenState extends State { labelText: tr( 'ui.userfront.signup.profile.company', ), - border: - const OutlineInputBorder(), + border: const OutlineInputBorder(), ), items: [ DropdownMenuItem( @@ -1557,7 +1556,9 @@ class _SignupScreenState extends State { _buildProfileFieldGroup( title: _affiliationType == 'AFFILIATE' ? tr('ui.userfront.signup.profile.department') - : tr('ui.userfront.signup.profile.department_optional'), + : tr( + 'ui.userfront.signup.profile.department_optional', + ), description: _affiliationType == 'AFFILIATE' ? '가족사 사용자는 부서명을 입력해주세요.' : '선택 입력 항목입니다.', @@ -1677,10 +1678,7 @@ class _SignupScreenState extends State { ], ), ), - if (trailing != null) ...[ - const SizedBox(width: 12), - trailing, - ], + if (trailing != null) ...[const SizedBox(width: 12), trailing], ], ), SizedBox(height: isDesktop ? 18 : 14), @@ -1793,13 +1791,22 @@ class _SignupScreenState extends State { hasTypeCount, ), if (requiresUpper) - _cryptoCheck(tr('msg.userfront.signup.password.rule.uppercase'), hasUpper), + _cryptoCheck( + tr('msg.userfront.signup.password.rule.uppercase'), + hasUpper, + ), if (requiresLower) - _cryptoCheck(tr('msg.userfront.signup.password.rule.lowercase'), hasLower), + _cryptoCheck( + tr('msg.userfront.signup.password.rule.lowercase'), + hasLower, + ), if (requiresNumber) _cryptoCheck(tr('msg.userfront.signup.password.rule.number'), hasDigit), if (requiresSymbol) - _cryptoCheck(tr('msg.userfront.signup.password.rule.symbol'), hasSpecial), + _cryptoCheck( + tr('msg.userfront.signup.password.rule.symbol'), + hasSpecial, + ), ]; return LayoutBuilder( @@ -1861,14 +1868,15 @@ class _SignupScreenState extends State { obscureText: _isPasswordObscured, onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.label'), + labelText: tr( + 'ui.userfront.signup.password.label', + ), border: const OutlineInputBorder(), errorText: _passwordError, suffixIcon: IconButton( onPressed: () { setState(() { - _isPasswordObscured = - !_isPasswordObscured; + _isPasswordObscured = !_isPasswordObscured; }); }, icon: Icon( @@ -1897,8 +1905,8 @@ class _SignupScreenState extends State { obscureText: _isConfirmPasswordObscured, onChanged: (val) { setState(() { - _confirmPasswordError = (val != - _passwordController.text) + _confirmPasswordError = + (val != _passwordController.text) ? tr('msg.userfront.signup.password.mismatch') : null; }); @@ -2032,11 +2040,7 @@ class _SignupScreenState extends State { ), child: Padding( padding: EdgeInsets.all(isDesktop ? 16 : 14), - child: Wrap( - spacing: 12, - runSpacing: 10, - children: checks, - ), + child: Wrap(spacing: 12, runSpacing: 10, children: checks), ), ); } diff --git a/userfront/test/profile_page_edit_flow_test.dart b/userfront/test/profile_page_edit_flow_test.dart index 0abfe01f..8ba7025b 100644 --- a/userfront/test/profile_page_edit_flow_test.dart +++ b/userfront/test/profile_page_edit_flow_test.dart @@ -10,7 +10,7 @@ class MockProfileNotifier extends ProfileNotifier { UserProfile? _profile; bool updateCalled = false; String? updatedName; - + @override Future build() async { _profile = UserProfile( @@ -33,7 +33,11 @@ class MockProfileNotifier extends ProfileNotifier { } @override - Future updateProfile({String? name, String? phone, String? department}) async { + Future updateProfile({ + String? name, + String? phone, + String? department, + }) async { updateCalled = true; updatedName = name; _profile = _profile!.copyWith( @@ -46,75 +50,82 @@ class MockProfileNotifier extends ProfileNotifier { } void main() { - testWidgets('ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)', (tester) async { - final recordedErrors = []; - 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; - }); + testWidgets( + 'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)', + (tester) async { + final recordedErrors = []; + 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); + 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()), + final mockNotifier = MockProfileNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [profileProvider.overrideWith(() => mockNotifier)], + child: const MaterialApp(home: Scaffold(body: ProfilePage())), ), - ), - ); + ); - await tester.pumpAndSettle(); + 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(); + // 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'); - }); + 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(); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + + FlutterError.onError = previousOnError; + + // Verify the mock received the update + expect(mockNotifier.updateCalled, isTrue); + expect(mockNotifier.updatedName, 'Saved Name'); + expect(recordedErrors, isEmpty); + }, + ); }