generated from ttipo/project-base
feat(flutter): 로그인·프로필·탈퇴를 서버에 연결
- 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다. 기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다 - 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤 기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다 - 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다 - 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다 - 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다 - 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다) - 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 서버 요청을 기다리고 성공했는지만 돌려준다.
|
||||
/// 실패 알림은 인터셉터(토스트)가 이미 띄웠으므로 화면은 메시지를 새로 만들지 않고 멈추기만 한다 (.claude/rules/error-codes.md).
|
||||
/// if (!await succeeded(notifier.save(input))) return;
|
||||
Future<bool> succeeded(Future<void> action) async {
|
||||
try {
|
||||
await action;
|
||||
return true;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) return false;
|
||||
rethrow;
|
||||
} on ApiException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -112,3 +112,24 @@ class SessionStore extends ChangeNotifier {
|
||||
|
||||
/// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트).
|
||||
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore(MemoryTokenVault()));
|
||||
|
||||
/// 로그인한 회원이 바뀐 횟수 — 로그인·로그아웃·세션 만료 때 1씩 오른다 (토큰 갱신은 같은 회원이라 그대로).
|
||||
/// 서버 기록을 들고 있는 알림기는 build 에서 이 값을 watch 해, 회원이 바뀌면 다시 불러온다.
|
||||
class SessionEpochNotifier extends Notifier<int> {
|
||||
@override
|
||||
int build() {
|
||||
final session = ref.watch(sessionStoreProvider);
|
||||
var signedIn = session.hasSession;
|
||||
void onChange() {
|
||||
if (session.hasSession == signedIn) return;
|
||||
signedIn = session.hasSession;
|
||||
state = state + 1;
|
||||
}
|
||||
|
||||
session.addListener(onChange);
|
||||
ref.onDispose(() => session.removeListener(onChange));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
final sessionEpochProvider = NotifierProvider<SessionEpochNotifier, int>(SessionEpochNotifier.new);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
|
||||
/// 로그인한 소셜 계정 종류·이름 — 화면에 바로 쓰려고 기기에 함께 둔다 (토큰은 secure storage)
|
||||
const String _kProviderKey = 'auth.provider';
|
||||
const String _kNicknameKey = 'auth.nickname';
|
||||
|
||||
/// 서버 인증 경로 (backend AuthController)
|
||||
const String _kDevSessionsPath = '/auth/dev-sessions';
|
||||
const String _kRevocationPath = '/auth/token-revocation';
|
||||
const String _kMePath = '/users/me';
|
||||
|
||||
/// 서버 로그인 — 토큰은 [SessionStore] 가 들고, 소셜 계정 종류·이름은 기기에 둔다.
|
||||
///
|
||||
/// 지금은 소셜 앱 키가 없어 **개발용 로그인**(기기 키)으로 가입·로그인한다 (운영 서버는 이 경로를 닫는다).
|
||||
/// 소셜 SDK 를 붙이면 SDK 로 받은 토큰을 `POST /auth/sessions {provider, token}` 으로 보내도록
|
||||
/// [signIn] 만 바꾸면 된다 — 응답 모양(토큰 + user)은 같다.
|
||||
class ApiAuthRepository implements AuthRepository {
|
||||
ApiAuthRepository({
|
||||
required Dio dio,
|
||||
required SessionStore session,
|
||||
required SharedPreferences prefs,
|
||||
required DeviceKeyStore deviceKeys,
|
||||
}) : _dio = dio,
|
||||
_session = session,
|
||||
_prefs = prefs,
|
||||
_deviceKeys = deviceKeys;
|
||||
|
||||
final Dio _dio;
|
||||
final SessionStore _session;
|
||||
final SharedPreferences _prefs;
|
||||
final DeviceKeyStore _deviceKeys;
|
||||
|
||||
@override
|
||||
bool get hasSession => _session.hasSession;
|
||||
|
||||
@override
|
||||
String? get nickname => _session.hasSession ? _prefs.getString(_kNicknameKey) : null;
|
||||
|
||||
@override
|
||||
SocialProvider? get provider =>
|
||||
_session.hasSession ? SocialProvider.values.asNameMap()[_prefs.getString(_kProviderKey)] : null;
|
||||
|
||||
@override
|
||||
Future<void> signIn(SocialProvider provider) async {
|
||||
final deviceKey = await _deviceKeys.readOrCreate();
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_kDevSessionsPath,
|
||||
data: {'provider': provider.name, 'deviceKey': deviceKey},
|
||||
);
|
||||
final data = response.data!;
|
||||
final user = data['user']! as Map<String, dynamic>;
|
||||
|
||||
await _prefs.setString(_kProviderKey, (user['provider'] as String?) ?? provider.name);
|
||||
final name = user['nickname'] as String?;
|
||||
name == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, name);
|
||||
// 세션을 마지막에 저장한다 — 저장하는 순간 진입 상태가 다음 화면으로 넘어간다
|
||||
await _session.save(AuthTokens.fromJson(data));
|
||||
}
|
||||
|
||||
/// 로그아웃 — 서버의 리프레시 토큰을 폐기하고 세션을 지운다.
|
||||
/// 폐기 요청이 실패해도(네트워크 등) 이 기기에서는 로그아웃한다 — 토큰은 어차피 만료된다.
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
final refresh = _session.tokens?.refreshToken;
|
||||
if (refresh != null) {
|
||||
try {
|
||||
await _dio.post<void>(
|
||||
_kRevocationPath,
|
||||
data: {'refreshToken': refresh},
|
||||
options: Options(extra: {kSilentExtra: true}),
|
||||
);
|
||||
} on DioException {
|
||||
// 기기에서는 그대로 로그아웃한다
|
||||
}
|
||||
}
|
||||
await _forget();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount() async {
|
||||
await _dio.delete<void>(_kMePath);
|
||||
await _forget();
|
||||
}
|
||||
|
||||
Future<void> _forget() async {
|
||||
await _prefs.remove(_kProviderKey);
|
||||
await _prefs.remove(_kNicknameKey);
|
||||
await _session.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
|
||||
/// 로그인 세션 관리 계약.
|
||||
/// 실제 구현은 소셜 SDK 로 받은 토큰을 백엔드 인증 API 에 넘기고,
|
||||
/// 발급받은 JWT 를 flutter_secure_storage(kJwtTokenKey)에 저장한다.
|
||||
/// 서버 구현(ApiAuthRepository)은 로그인으로 받은 토큰을 flutter_secure_storage(kJwtTokenKey)에 저장한다.
|
||||
abstract interface class AuthRepository {
|
||||
/// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다)
|
||||
bool get hasSession;
|
||||
@@ -19,12 +23,14 @@ abstract interface class AuthRepository {
|
||||
Future<void> signIn(SocialProvider provider);
|
||||
|
||||
Future<void> signOut();
|
||||
|
||||
/// 회원탈퇴 — 서버의 회원과 모든 기록을 지우고 세션을 끝낸다 (기기 저장 모드는 할 일이 없다)
|
||||
Future<void> deleteAccount();
|
||||
}
|
||||
|
||||
const String _kDemoSessionKey = 'auth.demoSession';
|
||||
|
||||
/// 임시 구현 — 백엔드 인증 API 와 소셜 SDK(앱 키 필요)가 준비되기 전까지
|
||||
/// 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다.
|
||||
/// 기기 저장 모드 — 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다.
|
||||
class DemoAuthRepository implements AuthRepository {
|
||||
DemoAuthRepository(this._prefs);
|
||||
|
||||
@@ -50,8 +56,20 @@ class DemoAuthRepository implements AuthRepository {
|
||||
Future<void> signOut() async {
|
||||
await _prefs.remove(_kDemoSessionKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount() async {}
|
||||
}
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return DemoAuthRepository(ref.watch(sharedPreferencesProvider));
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return switch (ref.watch(dataModeProvider)) {
|
||||
DataMode.local => DemoAuthRepository(prefs),
|
||||
DataMode.api => ApiAuthRepository(
|
||||
dio: ref.watch(dioProvider),
|
||||
session: ref.watch(sessionStoreProvider),
|
||||
prefs: prefs,
|
||||
deviceKeys: ref.watch(deviceKeyStoreProvider),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// 기기 키 저장 키 — 개발용 로그인의 계정 구분값
|
||||
const String kDeviceKeyKey = 'device_key';
|
||||
|
||||
/// 기기 키 길이 (바이트) — base64url 로 32자. 서버는 16~128자를 받는다
|
||||
const int _kDeviceKeyBytes = 24;
|
||||
|
||||
/// 개발용 로그인의 기기 키 — 소셜 앱 키가 준비되기 전, 소셜 계정 대신 이 기기를 한 회원으로 본다.
|
||||
/// 같은 키로 다시 로그인하면 같은 회원이므로, 로그인 수단처럼 secure storage 에 둔다.
|
||||
abstract interface class DeviceKeyStore {
|
||||
/// 저장된 키 — 없으면 새로 만들어 저장한다
|
||||
Future<String> readOrCreate();
|
||||
}
|
||||
|
||||
class SecureDeviceKeyStore implements DeviceKeyStore {
|
||||
const SecureDeviceKeyStore([this._storage = const FlutterSecureStorage()]);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<String> readOrCreate() async {
|
||||
final saved = await _storage.read(key: kDeviceKeyKey);
|
||||
if (saved != null) return saved;
|
||||
final key = newDeviceKey(Random.secure());
|
||||
await _storage.write(key: kDeviceKeyKey, value: key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트용 — 정해진 키를 돌려준다
|
||||
class FixedDeviceKeyStore implements DeviceKeyStore {
|
||||
const FixedDeviceKeyStore(this.key);
|
||||
|
||||
final String key;
|
||||
|
||||
@override
|
||||
Future<String> readOrCreate() async => key;
|
||||
}
|
||||
|
||||
/// 추측할 수 없는 기기 키 (base64url, 패딩 없음)
|
||||
String newDeviceKey(Random random) {
|
||||
final bytes = List<int>.generate(_kDeviceKeyBytes, (_) => random.nextInt(256));
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
final deviceKeyStoreProvider = Provider<DeviceKeyStore>((ref) => const SecureDeviceKeyStore());
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
import 'package:tma_app/features/entry/data/entry_repository.dart';
|
||||
@@ -11,6 +12,18 @@ class EntryNotifier extends Notifier<EntryState> {
|
||||
|
||||
@override
|
||||
EntryState build() {
|
||||
// 서버가 세션을 끊으면(토큰 갱신 거절 — AUTH_002) 로그인 화면으로 돌아간다.
|
||||
// 안내 토스트는 인터셉터가 이미 띄웠다. 로그아웃처럼 인트로를 거치지 않는다.
|
||||
final session = ref.watch(sessionStoreProvider);
|
||||
void onSession() {
|
||||
if (session.expired && state.signedIn) {
|
||||
state = state.copyWith(signedIn: false, introPassed: true);
|
||||
}
|
||||
}
|
||||
|
||||
session.addListener(onSession);
|
||||
ref.onDispose(() => session.removeListener(onSession));
|
||||
|
||||
return EntryState(
|
||||
introPassed: false,
|
||||
signedIn: ref.watch(authRepositoryProvider).hasSession,
|
||||
|
||||
@@ -1,44 +1,176 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
|
||||
const String _kNicknameKey = 'profile.nickname';
|
||||
const String _kPhotoKey = 'profile.photo';
|
||||
|
||||
/// 서버 모드에서 마지막으로 받은 내 프로필 — 앱을 열자마자 보여 주려고 기기에 둔다
|
||||
const String _kApiNicknameKey = 'profile.api.nickname';
|
||||
const String _kApiPhotoKey = 'profile.api.photo';
|
||||
|
||||
/// 내 정보 경로 (backend UserController)
|
||||
const String _kMePath = '/users/me';
|
||||
|
||||
/// 내 프로필 — 앱에서 바꾼 닉네임·사진 (없으면 소셜 계정 값 / 기본값을 쓴다)
|
||||
@immutable
|
||||
class UserProfile {
|
||||
const UserProfile({this.nickname, this.photoPath});
|
||||
|
||||
final String? nickname;
|
||||
|
||||
/// 기기 사진 경로 또는 서버 파일(`server-file:<id>`)
|
||||
final String? photoPath;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is UserProfile && other.nickname == nickname && other.photoPath == photoPath;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(nickname, photoPath);
|
||||
}
|
||||
|
||||
/// 프로필 저장 — 서버 회원 API 가 생기면 그쪽으로 옮긴다
|
||||
class ProfileRepository {
|
||||
ProfileRepository(this._prefs);
|
||||
/// 프로필 저장소 계약
|
||||
abstract interface class ProfileRepository {
|
||||
/// 화면에 바로 쓰는 값 — 기기에 둔 값 (서버 모드는 마지막으로 받은 값)
|
||||
UserProfile load();
|
||||
|
||||
/// 최신 값 — 서버 모드는 서버에서 받는다
|
||||
Future<UserProfile> fetch();
|
||||
|
||||
/// 저장하고 저장된 값을 돌려준다
|
||||
Future<UserProfile> save(UserProfile profile);
|
||||
|
||||
/// 로그아웃할 때 — 서버 모드는 기기에 둔 프로필을 지운다 (다음에 로그인할 회원에게 보이지 않게)
|
||||
Future<void> forget();
|
||||
}
|
||||
|
||||
Future<void> _writeOrRemove(SharedPreferences prefs, String key, String? value) =>
|
||||
value == null ? prefs.remove(key) : prefs.setString(key, value);
|
||||
|
||||
/// 기기 저장 모드 — 프로필을 이 기기에만 둔다
|
||||
class LocalProfileRepository implements ProfileRepository {
|
||||
LocalProfileRepository(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
UserProfile load() => UserProfile(
|
||||
nickname: _prefs.getString(_kNicknameKey),
|
||||
photoPath: _prefs.getString(_kPhotoKey),
|
||||
);
|
||||
|
||||
Future<void> save(UserProfile profile) async {
|
||||
final nickname = profile.nickname;
|
||||
final photo = profile.photoPath;
|
||||
nickname == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, nickname);
|
||||
photo == null ? await _prefs.remove(_kPhotoKey) : await _prefs.setString(_kPhotoKey, photo);
|
||||
@override
|
||||
Future<UserProfile> fetch() async => load();
|
||||
|
||||
@override
|
||||
Future<UserProfile> save(UserProfile profile) async {
|
||||
await _writeOrRemove(_prefs, _kNicknameKey, profile.nickname);
|
||||
await _writeOrRemove(_prefs, _kPhotoKey, profile.photoPath);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// 로그아웃해도 기록은 기기에 남는다
|
||||
@override
|
||||
Future<void> forget() async {}
|
||||
}
|
||||
|
||||
/// 서버 모드 — GET/PATCH /users/me. 사진은 저장할 때 올린다
|
||||
class ApiProfileRepository implements ProfileRepository {
|
||||
ApiProfileRepository({
|
||||
required Dio dio,
|
||||
required SessionStore session,
|
||||
required FileUploader uploader,
|
||||
required SharedPreferences prefs,
|
||||
}) : _dio = dio,
|
||||
_session = session,
|
||||
_uploader = uploader,
|
||||
_prefs = prefs;
|
||||
|
||||
final Dio _dio;
|
||||
final SessionStore _session;
|
||||
final FileUploader _uploader;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
UserProfile load() => UserProfile(
|
||||
nickname: _prefs.getString(_kApiNicknameKey),
|
||||
photoPath: _prefs.getString(_kApiPhotoKey),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<UserProfile> fetch() async {
|
||||
// 로그인 전에는 부르지 않는다 (로그인 화면에서 "로그인이 필요" 알림이 뜨지 않게)
|
||||
if (!_session.hasSession) return load();
|
||||
final response = await _dio.get<Map<String, dynamic>>(_kMePath);
|
||||
return _remember(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile> save(UserProfile profile) async {
|
||||
final photoFileId = await _uploader.ensureUploaded(profile.photoPath);
|
||||
final response = await _dio.patch<Map<String, dynamic>>(
|
||||
_kMePath,
|
||||
data: {'nickname': ?profile.nickname, 'photoFileId': photoFileId},
|
||||
);
|
||||
return _remember(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> forget() async {
|
||||
await _prefs.remove(_kApiNicknameKey);
|
||||
await _prefs.remove(_kApiPhotoKey);
|
||||
}
|
||||
|
||||
Future<UserProfile> _remember(Map<String, dynamic> me) async {
|
||||
final profile = UserProfile(
|
||||
nickname: me['nickname'] as String?,
|
||||
photoPath: serverFileRefOrNull(me['photoFileId'] as String?),
|
||||
);
|
||||
await _writeOrRemove(_prefs, _kApiNicknameKey, profile.nickname);
|
||||
await _writeOrRemove(_prefs, _kApiPhotoKey, profile.photoPath);
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
|
||||
return ProfileRepository(ref.watch(sharedPreferencesProvider));
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return switch (ref.watch(dataModeProvider)) {
|
||||
DataMode.local => LocalProfileRepository(prefs),
|
||||
DataMode.api => ApiProfileRepository(
|
||||
dio: ref.watch(dioProvider),
|
||||
session: ref.watch(sessionStoreProvider),
|
||||
uploader: ref.watch(fileUploaderProvider),
|
||||
prefs: prefs,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
class ProfileNotifier extends Notifier<UserProfile> {
|
||||
@override
|
||||
UserProfile build() => ref.watch(profileRepositoryProvider).load();
|
||||
UserProfile build() {
|
||||
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
|
||||
ref.watch(sessionEpochProvider);
|
||||
final repo = ref.watch(profileRepositoryProvider);
|
||||
// 기기에 둔 값으로 먼저 그리고, 최신 값이 오면 바꾼다 — 실패 알림은 인터셉터가 띄운다
|
||||
unawaited(succeeded(_refresh(repo)));
|
||||
return repo.load();
|
||||
}
|
||||
|
||||
Future<void> _refresh(ProfileRepository repo) async {
|
||||
final latest = await repo.fetch();
|
||||
if (ref.mounted) state = latest;
|
||||
}
|
||||
|
||||
/// 넘긴 값만 바꾼다 (null 이면 기존 값 유지)
|
||||
Future<void> update({String? nickname, String? photoPath}) async {
|
||||
@@ -46,8 +178,8 @@ class ProfileNotifier extends Notifier<UserProfile> {
|
||||
nickname: nickname ?? state.nickname,
|
||||
photoPath: photoPath ?? state.photoPath,
|
||||
);
|
||||
await ref.read(profileRepositoryProvider).save(next);
|
||||
state = next;
|
||||
final saved = await ref.read(profileRepositoryProvider).save(next);
|
||||
if (ref.mounted) state = saved;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
@@ -17,12 +19,18 @@ class AccountActions {
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// 로그아웃 — 기록은 기기에 남는다. 라우터가 로그인 화면으로 보낸다
|
||||
Future<void> logout() => _ref.read(entryProvider.notifier).signOut();
|
||||
/// 로그아웃 — 기록은 서버(서버 모드) 또는 기기(기기 저장 모드)에 남는다. 라우터가 로그인 화면으로 보낸다.
|
||||
/// 서버 모드는 다음에 로그인할 회원에게 보이지 않게 기기에 둔 프로필을 지운다.
|
||||
Future<void> logout() async {
|
||||
await _ref.read(entryProvider.notifier).signOut();
|
||||
await _ref.read(profileRepositoryProvider).forget();
|
||||
}
|
||||
|
||||
/// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다.
|
||||
/// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다.
|
||||
/// 회원탈퇴 — 서버의 회원과 기록을 지우고(서버 모드), 이 기기에 둔 기록·사진·설정도 모두 지운 뒤 처음(인트로)으로 돌아간다.
|
||||
/// 서버 탈퇴가 실패하면(알림은 인터셉터가 띄운다) 기기의 기록도 그대로 둔다.
|
||||
Future<void> withdraw() async {
|
||||
if (!await succeeded(_ref.read(authRepositoryProvider).deleteAccount())) return;
|
||||
|
||||
// 기록 삭제와 화면 전환이 먼저다 — 사진 폴더 정리가 늦거나 실패해도 탈퇴는 끝나야 한다
|
||||
final photos = _ref.read(petPhotoStoreProvider);
|
||||
await _ref.read(sharedPreferencesProvider).clear();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
@@ -68,10 +69,12 @@ class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
|
||||
Future<void> _submit() async {
|
||||
final name = _name.text.trim();
|
||||
// 비워 두면 닉네임은 바꾸지 않는다 (시안: 값이 있을 때만 반영)
|
||||
await ref.read(profileProvider.notifier).update(
|
||||
final saved = await succeeded(ref.read(profileProvider.notifier).update(
|
||||
nickname: name.isEmpty ? null : name,
|
||||
photoPath: _photoPath,
|
||||
);
|
||||
));
|
||||
// 실패 알림은 인터셉터가 띄웠다 — 시트를 열어 둔 채 다시 시도할 수 있게 한다
|
||||
if (!saved) return;
|
||||
ref.read(toastProvider.notifier).show('프로필을 수정했어요', icon: RupIconName.check);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
|
||||
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
|
||||
class UserModel {
|
||||
const UserModel({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String email;
|
||||
final String name;
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
id: json['id'] as int,
|
||||
email: json['email'] as String,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/user_model.dart';
|
||||
|
||||
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
|
||||
///
|
||||
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
|
||||
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
|
||||
class UserRepository {
|
||||
const UserRepository(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<UserModel> getUser(int id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
|
||||
return UserModel.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// 커서 기반 목록 조회 — 목록 API 는 항상 페이지네이션을 적용한다.
|
||||
Future<({List<UserModel> items, String? nextCursor, bool hasNext})> getUsers({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/users',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'cursor': ?cursor,
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data!;
|
||||
final items = (data['items'] as List<dynamic>)
|
||||
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
|
||||
|
||||
return (
|
||||
items: items,
|
||||
nextCursor: pageInfo['nextCursor'] as String?,
|
||||
hasNext: pageInfo['hasNext'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/api/dio_provider.dart';
|
||||
import '../../data/models/user_model.dart';
|
||||
import '../../data/repositories/user_repository.dart';
|
||||
|
||||
// 리포지토리 Provider — dioProvider 를 주입받아 생성
|
||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||
return UserRepository(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
// 특정 사용자 조회 Provider (family)
|
||||
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
|
||||
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
|
||||
return ref.watch(userRepositoryProvider).getUser(id);
|
||||
});
|
||||
Reference in New Issue
Block a user