forked from baron/baron-sso
userfront로 리펙토링 완료
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
class UserProfile {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String department;
|
||||
final String affiliationType;
|
||||
final String companyCode;
|
||||
|
||||
UserProfile({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.department,
|
||||
required this.affiliationType,
|
||||
required this.companyCode,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||
return UserProfile(
|
||||
id: json['id'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
phone: json['phone'] ?? '',
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
};
|
||||
}
|
||||
|
||||
UserProfile copyWith({
|
||||
String? name,
|
||||
String? phone,
|
||||
String? department,
|
||||
}) {
|
||||
return UserProfile(
|
||||
id: id,
|
||||
email: email,
|
||||
name: name ?? this.name,
|
||||
phone: phone ?? this.phone,
|
||||
department: department ?? this.department,
|
||||
affiliationType: affiliationType,
|
||||
companyCode: companyCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
final session = await Descope.sessionManager.session;
|
||||
return session?.sessionToken.jwt;
|
||||
}
|
||||
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UserProfile.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
throw Exception('Failed to load profile: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateMyProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update profile: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendUpdateCode(String phone) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'phone': phone}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증번호 전송 실패: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'phone': phone, 'code': code}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증 실패: ${response.body}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../data/repositories/profile_repository.dart';
|
||||
|
||||
// 1. Repository Provider
|
||||
final profileRepositoryProvider = Provider((ref) => ProfileRepository());
|
||||
|
||||
// 2. AsyncNotifier implementation (Modern Riverpod)
|
||||
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
@override
|
||||
FutureOr<UserProfile?> build() async {
|
||||
// Initial data fetch
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<UserProfile?> _fetch() async {
|
||||
return ref.read(profileRepositoryProvider).getMyProfile();
|
||||
}
|
||||
|
||||
Future<UserProfile?> loadProfile() async {
|
||||
state = const AsyncValue.loading();
|
||||
final profile = await _fetch();
|
||||
state = AsyncValue.data(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
// Show loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Perform update and then re-fetch profile
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileRepositoryProvider).updateMyProfile(
|
||||
name: name,
|
||||
phone: phone,
|
||||
department: department,
|
||||
);
|
||||
return _fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider definition
|
||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
|
||||
return ProfileNotifier();
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class EditProfilePage extends ConsumerStatefulWidget {
|
||||
const EditProfilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
|
||||
}
|
||||
|
||||
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _phoneController;
|
||||
late TextEditingController _codeController;
|
||||
late TextEditingController _departmentController;
|
||||
|
||||
String? _initialPhone;
|
||||
bool _isPhoneChanged = false;
|
||||
bool _isPhoneVerified = false;
|
||||
bool _isCodeSent = false;
|
||||
bool _isVerifying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final profile = ref.read(profileProvider).value;
|
||||
_initialPhone = profile?.phone ?? '';
|
||||
_nameController = TextEditingController(text: profile?.name ?? '');
|
||||
_phoneController = TextEditingController(text: _initialPhone);
|
||||
_codeController = TextEditingController();
|
||||
_departmentController = TextEditingController(text: profile?.department ?? '');
|
||||
|
||||
_phoneController.addListener(() {
|
||||
setState(() {
|
||||
_isPhoneChanged = _phoneController.text != _initialPhone;
|
||||
if (_isPhoneChanged) {
|
||||
_isPhoneVerified = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_codeController.dispose();
|
||||
_departmentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
final phone = _phoneController.text;
|
||||
if (phone.isEmpty) return;
|
||||
|
||||
setState(() => _isVerifying = true);
|
||||
try {
|
||||
await ref.read(profileRepositoryProvider).sendUpdateCode(phone);
|
||||
setState(() {
|
||||
_isCodeSent = true;
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('인증번호가 전송되었습니다.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('전송 실패: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyCode() async {
|
||||
final phone = _phoneController.text;
|
||||
final code = _codeController.text;
|
||||
if (code.isEmpty) return;
|
||||
|
||||
setState(() => _isVerifying = true);
|
||||
try {
|
||||
await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code);
|
||||
setState(() {
|
||||
_isPhoneVerified = true;
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('인증되었습니다.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('인증 실패: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(profileProvider.notifier).updateProfile(
|
||||
name: _nameController.text,
|
||||
phone: _phoneController.text,
|
||||
department: _departmentController.text,
|
||||
);
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('정보가 수정되었습니다.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('수정 실패: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profileState = ref.watch(profileProvider);
|
||||
final isUpdating = profileState.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('내 정보 수정'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: (isUpdating || (_isPhoneChanged && !_isPhoneVerified)) ? null : _save,
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이름',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) => (value == null || value.isEmpty) ? '이름을 입력해주세요.' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Phone Number Field
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '휴대폰 번호',
|
||||
hintText: '01012345678',
|
||||
prefixIcon: const Icon(Icons.phone_android),
|
||||
suffixIcon: _isPhoneVerified
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: !_isPhoneVerified,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isPhoneChanged && !_isPhoneVerified)
|
||||
ElevatedButton(
|
||||
onPressed: _isVerifying ? null : _sendCode,
|
||||
child: Text(_isCodeSent ? '재전송' : '인증요청'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// OTP Code Field
|
||||
if (_isCodeSent && !_isPhoneVerified) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _codeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '인증번호',
|
||||
hintText: '6자리 입력',
|
||||
prefixIcon: Icon(Icons.security),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isVerifying ? null : _verifyCode,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue[700], foregroundColor: Colors.white),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
if (_isPhoneChanged && !_isPhoneVerified)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0, left: 4.0),
|
||||
child: Text(
|
||||
'휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _departmentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '소속 (부서)',
|
||||
prefixIcon: Icon(Icons.business),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
if (isUpdating || _isVerifying)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../domain/notifiers/profile_notifier.dart';
|
||||
import '../widgets/profile_info_row.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// profileState is AsyncValue<UserProfile?>
|
||||
final profileState = ref.watch(profileProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('내 정보'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => context.push('/profile/edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: profileState.when(
|
||||
data: (profile) {
|
||||
if (profile == null) {
|
||||
return const Center(child: Text('정보를 불러올 수 없습니다.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
const Center(
|
||||
child: CircleAvatar(
|
||||
radius: 40,
|
||||
child: Icon(Icons.person, size: 40),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ProfileInfoRow(label: '이름', value: profile.name),
|
||||
ProfileInfoRow(label: '이메일', value: profile.email),
|
||||
ProfileInfoRow(label: '전화번호', value: profile.phone),
|
||||
const Divider(height: 32),
|
||||
ProfileInfoRow(label: '소속', value: profile.department),
|
||||
ProfileInfoRow(label: '구분', value: profile.affiliationType),
|
||||
if (profile.companyCode.isNotEmpty)
|
||||
ProfileInfoRow(label: '회사코드', value: profile.companyCode),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('오류 발생: $err'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||
child: const Text('재시도'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfileInfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const ProfileInfoRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user