generated from ttipo/project-base
feat(flutter): 앱 로그인 · 토큰 갱신 · 모듈 라우터 기반을 만든다
앱 1단계의 바탕이다(계약 1절 "앱 로그인" — 서버는 다음 차례, 지금은 가짜 서버로 시험한다). - 토큰: 리프레시 토큰은 보안 저장소에만, 액세스 토큰은 메모리에만. 첫 실행 표시가 없으면 남은 토큰을 지우고, 안드로이드 백업을 끈다. 보안 저장소 일은 차례로 한다 - 인터셉터: 401 이면 갱신 한 번(동시 401 묶음) 뒤 다시 보내기, AUTH_010 은 me 를 다시 받아 그 단계로, 미리 갱신 30초 전 · 만료는 응답 Date 로, 세션 세대가 바뀐 뒤의 응답은 조용히 버린다, 비밀이 든 요청의 실패 예외는 본문 · Bearer 를 지운다 - 세션: 로그인 · 코드 · 새 비밀번호 · 2단계 켜기 단계의 상태와 늦은 결과 버리기(버린 로그인의 토큰은 조용히 logout), 로그아웃은 메모리부터 비운다, 유예 응답이면 세션 끝 + 조용한 logout - 모듈 틀: go_router StatefulShellRoute 와 아래 탭 막대 — 모듈 목록은 웹 app-modules.ts 와 짝, 첫 판은 [메일 · 설정]. 모듈끼리 import 하지 않는다 - Riverpod 자동 다시 하기를 끈다, Pretendard 1.3.9(OFL), 확정 글자 크기, 시험 126건 - 판은 Flutter 3.41.9(Dart 3.11.5) — pubspec 의 sdk 하한과 lock 을 함께 리뷰 세 번 반영(AUTH_010 다시 보내기 · 늦은 응답 · 저장 실패 · 로그아웃 차례 · 비밀이 실린 객체 등). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import 'package:arasystem_app/core/storage/auth_tokens.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
|
||||
|
||||
/// 로그인 단계의 결과 (계약 5절 "로그인" — `status`)
|
||||
enum LoginStatus {
|
||||
/// 로그인했다 — 토큰이 실려 온다
|
||||
signedIn,
|
||||
|
||||
/// 비밀번호는 맞고 2단계 인증 코드가 필요하다 — 토큰 없음, `email` · `name` 은 null
|
||||
twoFactorRequired,
|
||||
}
|
||||
|
||||
/// `POST /api/auth/app/login` 의 응답 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"
|
||||
/// (웹 login 과 같은 평평한 칸 `status` · `email` · `name` · `restriction` · `twoFactorDeadline` + `tokens`)
|
||||
class AppLoginResult {
|
||||
const AppLoginResult({required this.status, this.session, this.tokens});
|
||||
|
||||
final LoginStatus status;
|
||||
|
||||
/// `signedIn` 일 때 직원 · 세션 칸
|
||||
final AuthSessionInfo? session;
|
||||
|
||||
/// `signedIn` 일 때만 — 코드 단계는 null
|
||||
final AuthTokens? tokens;
|
||||
|
||||
/// 응답을 읽는다. `signedIn` 인데 세션 칸 · 토큰이 없으면 서버와 계약이 어긋난 것이라 [FormatException].
|
||||
/// [serverTime] 은 응답의 `Date` 머리 — 토큰의 남은 시간을 서버 시계로 잰다.
|
||||
factory AppLoginResult.fromJson(
|
||||
Map<String, Object?> json, {
|
||||
DateTime? serverTime,
|
||||
}) {
|
||||
final Object? status = json['status'];
|
||||
if (status == LoginStatus.twoFactorRequired.name) {
|
||||
return const AppLoginResult(status: LoginStatus.twoFactorRequired);
|
||||
}
|
||||
if (status != LoginStatus.signedIn.name) {
|
||||
throw const FormatException('로그인 응답의 status 를 알 수 없다');
|
||||
}
|
||||
final AuthSessionInfo? session = AuthSessionInfo.tryParse(json);
|
||||
final AuthTokens? tokens = AuthTokens.tryParse(
|
||||
json['tokens'],
|
||||
serverTime: serverTime,
|
||||
);
|
||||
if (session == null || tokens == null) {
|
||||
throw const FormatException('signedIn 응답에 직원 · 토큰 칸이 없다');
|
||||
}
|
||||
return AppLoginResult(
|
||||
status: LoginStatus.signedIn,
|
||||
session: session,
|
||||
tokens: tokens,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/// 제한된 세션 (계약 6절) — 로그인은 됐지만 그 단계를 마칠 때까지 다른 API 는 403 `AUTH_010` 이다
|
||||
enum SessionRestriction {
|
||||
/// 새 비밀번호를 정할 때까지
|
||||
passwordChange,
|
||||
|
||||
/// 2단계 인증을 켤 때까지
|
||||
twoFactorSetup,
|
||||
|
||||
/// 앱이 모르는 제한 — 웹처럼 갱신 · 다시 보내기 없이 사전 문구와 [다른 계정으로 로그인]만 보인다
|
||||
unknown;
|
||||
|
||||
/// 응답 칸을 읽는다 — 없거나 빈 값이면 제한 없음(null), 아는 이름이면 그 이름, 그 밖은 [unknown]
|
||||
static SessionRestriction? parse(Object? value) {
|
||||
if (value == null || value == '') return null;
|
||||
for (final SessionRestriction restriction in SessionRestriction.values) {
|
||||
if (restriction != unknown && restriction.name == value) {
|
||||
return restriction;
|
||||
}
|
||||
}
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그인한 직원과 세션 칸 — 로그인(`signedIn`) · me · 갱신 응답이 함께 싣는다(계약 1 · 6절)
|
||||
class AuthSessionInfo {
|
||||
const AuthSessionInfo({
|
||||
required this.email,
|
||||
required this.name,
|
||||
this.restriction,
|
||||
this.twoFactorDeadline,
|
||||
});
|
||||
|
||||
final String email;
|
||||
final String name;
|
||||
|
||||
/// 제한 — null 이면 제한 없음
|
||||
final SessionRestriction? restriction;
|
||||
|
||||
/// 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각(UTC) — 안내 줄은 그 1ms 앞 날짜까지
|
||||
final DateTime? twoFactorDeadline;
|
||||
|
||||
/// 응답의 세션 칸을 읽는다 — `email` · `name` 이 없으면(코드 단계 · 토큰만 실린 갱신 응답) null
|
||||
static AuthSessionInfo? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? email = json['email'];
|
||||
final Object? name = json['name'];
|
||||
if (email is! String || email.isEmpty || name is! String) return null;
|
||||
final Object? deadline = json['twoFactorDeadline'];
|
||||
return AuthSessionInfo(
|
||||
email: email,
|
||||
name: name,
|
||||
restriction: SessionRestriction.parse(json['restriction']),
|
||||
twoFactorDeadline: deadline is String
|
||||
? DateTime.tryParse(deadline)?.toUtc()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is AuthSessionInfo &&
|
||||
other.email == email &&
|
||||
other.name == name &&
|
||||
other.restriction == restriction &&
|
||||
other.twoFactorDeadline == twoFactorDeadline;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(email, name, restriction, twoFactorDeadline);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/api/request_flags.dart';
|
||||
import 'package:arasystem_app/core/api/server_time.dart';
|
||||
import 'package:arasystem_app/core/api/token_refresher.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/app_login_result.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 로그인 화면이 칸 아래 · 위쪽 상자에 직접 보이는 실패 — 알림을 띄우지 않는다
|
||||
/// (계약 5절 "클라이언트": `AUTH_004` 위쪽 상자, `AUTH_006` · `AUTH_007` 코드 칸 아래, `VAL_001` 칸 검사)
|
||||
const Set<String> kLoginInlineCodes = <String>{
|
||||
'AUTH_004',
|
||||
'AUTH_006',
|
||||
'AUTH_007',
|
||||
'VAL_001',
|
||||
};
|
||||
|
||||
/// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다
|
||||
const String kMalformedResponseCode = 'SYS_001';
|
||||
|
||||
/// 앱 로그인 API — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"(서버는 아직 없다 — 시험은 가짜 서버로 한다).
|
||||
///
|
||||
/// 응답 언래핑과 에러 코드는 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
|
||||
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [kMalformedResponseCode].
|
||||
/// 비밀번호 · 코드 · 토큰은 로그 · 예외에 싣지 않는다 — 비밀이 든 요청(`ApiOptions.secret`)의 실패 예외는 본문과 Bearer 를 비운 채 온다.
|
||||
class AuthRepository {
|
||||
const AuthRepository(this._dio, this._refresher);
|
||||
|
||||
final Dio _dio;
|
||||
final TokenRefresher _refresher;
|
||||
|
||||
/// 로그인 — 2단계 인증을 켠 직원은 코드 단계에서 [otpCode] 와 비밀번호를 한 번 더 보낸다(계약 5절)
|
||||
Future<AppLoginResult> login({
|
||||
required String email,
|
||||
required String password,
|
||||
String? otpCode,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.post<Object?>(
|
||||
ApiPaths.appLogin,
|
||||
data: <String, Object?>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
'otpCode': ?otpCode,
|
||||
},
|
||||
// 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다)
|
||||
options: ApiOptions.secret(silent: kLoginInlineCodes),
|
||||
);
|
||||
final Object? body = response.data;
|
||||
if (body is Map<String, Object?>) {
|
||||
try {
|
||||
return AppLoginResult.fromJson(
|
||||
body,
|
||||
serverTime: serverTimeOf(response),
|
||||
);
|
||||
} on FormatException {
|
||||
// 아래에서 같은 실패로 던진다
|
||||
}
|
||||
}
|
||||
throw _malformed(response);
|
||||
}
|
||||
|
||||
/// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기)
|
||||
Future<AuthSessionInfo> me({bool silent = false}) async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
ApiPaths.me,
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final AuthSessionInfo? info = AuthSessionInfo.tryParse(response.data);
|
||||
if (info == null) throw _malformed(response);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 지금 세션을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때만 쓴다
|
||||
/// (인터셉터가 그 결과를 기다리는 중이라 같은 줄로 보내면 서로 기다린다). 알림은 띄우지 않는다.
|
||||
Future<AuthSessionInfo> readSessionDirect() async {
|
||||
final Map<String, Object?> body = await _refresher.readSession();
|
||||
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
|
||||
if (info == null) {
|
||||
throw apiFailure(
|
||||
RequestOptions(path: ApiPaths.me),
|
||||
kMalformedResponseCode,
|
||||
);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나).
|
||||
/// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다.
|
||||
Future<RefreshOutcome> refresh() => _refresher.refresh();
|
||||
|
||||
/// 서버 세션을 끝낸다 — 리프레시 토큰을 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다.
|
||||
/// 서버는 어느 쪽도 맞지 않아도 성공으로 답한다. 알림은 띄우지 않는다.
|
||||
Future<void> logout({
|
||||
required String? refreshToken,
|
||||
required String? accessToken,
|
||||
}) =>
|
||||
postAppLogout(_dio, refreshToken: refreshToken, accessToken: accessToken);
|
||||
|
||||
DioException _malformed(Response<Object?> response) => withoutSecrets(
|
||||
apiFailure(
|
||||
response.requestOptions,
|
||||
kMalformedResponseCode,
|
||||
response: response,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
|
||||
/// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계)
|
||||
const Duration kLoginSecretLifetime = Duration(minutes: 10);
|
||||
|
||||
/// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다.
|
||||
///
|
||||
/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다).
|
||||
/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분.
|
||||
/// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다.
|
||||
class LoginSecret {
|
||||
LoginSecret({
|
||||
Clock clock = systemClock,
|
||||
Duration lifetime = kLoginSecretLifetime,
|
||||
}) : _clock = clock,
|
||||
_lifetime = lifetime;
|
||||
|
||||
final Clock _clock;
|
||||
final Duration _lifetime;
|
||||
|
||||
String? _email;
|
||||
String? _password;
|
||||
DateTime? _confirmedAt;
|
||||
Timer? _timer;
|
||||
|
||||
/// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다
|
||||
void hold(String email, String password) {
|
||||
clear();
|
||||
_email = email;
|
||||
_password = password;
|
||||
renew();
|
||||
}
|
||||
|
||||
/// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다
|
||||
void renew() {
|
||||
if (_password == null) return;
|
||||
_confirmedAt = _clock();
|
||||
_timer?.cancel();
|
||||
_timer = Timer(_lifetime, clear);
|
||||
}
|
||||
|
||||
/// 쥐고 있는 주소 · 비밀번호 — 없거나 10분이 지났으면 null(지난 것은 지운다)
|
||||
({String email, String password})? read() {
|
||||
final String? email = _email;
|
||||
final String? password = _password;
|
||||
final DateTime? confirmedAt = _confirmedAt;
|
||||
if (email == null || password == null || confirmedAt == null) return null;
|
||||
if (_clock().difference(confirmedAt) >= _lifetime) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
return (email: email, password: password);
|
||||
}
|
||||
|
||||
/// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다)
|
||||
bool get isHeld => _password != null;
|
||||
|
||||
void clear() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_email = null;
|
||||
_password = null;
|
||||
_confirmedAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/api/session_events.dart';
|
||||
import 'package:arasystem_app/core/api/token_refresher.dart';
|
||||
import 'package:arasystem_app/core/push/push_registration.dart';
|
||||
import 'package:arasystem_app/core/storage/auth_tokens.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/app_login_result.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
|
||||
import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 인증 저장소
|
||||
final Provider<AuthRepository> authRepositoryProvider =
|
||||
Provider<AuthRepository>(
|
||||
(Ref ref) => AuthRepository(
|
||||
ref.watch(dioProvider),
|
||||
ref.watch(tokenRefresherProvider),
|
||||
),
|
||||
);
|
||||
|
||||
/// 세션 — 앱에 하나. 라우터 redirect 가 이것을 보고 화면을 고른다.
|
||||
///
|
||||
/// 되살리기가 장애로 실패해도 저절로 다시 하지 않는다(`noAutomaticRetry` — Riverpod 3 은 `retry` 를 주지 않으면 기본으로 다시 한다).
|
||||
/// 시작 화면이 [SessionNotifier.retry] 를 부른다. 앱 전체도 ProviderScope 의 `retry` 로 끈다(main.dart).
|
||||
final AsyncNotifierProvider<SessionNotifier, SessionState> sessionProvider =
|
||||
AsyncNotifierProvider<SessionNotifier, SessionState>(
|
||||
SessionNotifier.new,
|
||||
retry: noAutomaticRetry,
|
||||
);
|
||||
|
||||
/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기.
|
||||
///
|
||||
/// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다.
|
||||
/// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다.
|
||||
/// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다.
|
||||
/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다.
|
||||
/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의
|
||||
/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게).
|
||||
class SessionNotifier extends AsyncNotifier<SessionState> {
|
||||
LoginSecret? _secretHolder;
|
||||
Future<RestrictionCheck>? _rechecking;
|
||||
int _epoch = 0;
|
||||
|
||||
LoginSecret get _secret =>
|
||||
_secretHolder ??= LoginSecret(clock: ref.read(clockProvider));
|
||||
|
||||
AuthRepository get _repository => ref.read(authRepositoryProvider);
|
||||
|
||||
TokenStore get _store => ref.read(tokenStoreProvider);
|
||||
|
||||
TokenRefresher get _refresher => ref.read(tokenRefresherProvider);
|
||||
|
||||
PushRegistrar get _push => ref.read(pushRegistrarProvider);
|
||||
|
||||
@override
|
||||
Future<SessionState> build() async {
|
||||
final StreamSubscription<SessionEvent> subscription = ref
|
||||
.watch(sessionEventsProvider)
|
||||
.stream
|
||||
.listen(_onEvent);
|
||||
ref.onDispose(() {
|
||||
unawaited(subscription.cancel());
|
||||
_secretHolder?.clear();
|
||||
});
|
||||
|
||||
final TokenStore store = ref.watch(tokenStoreProvider);
|
||||
if (!store.hasSession) return const SignedOut();
|
||||
return _restore();
|
||||
}
|
||||
|
||||
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
|
||||
({String email, String password})? get heldLogin => _secret.read();
|
||||
|
||||
/// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다
|
||||
void retry() {
|
||||
_refresher.forgetFailure();
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
/// 비밀번호 단계
|
||||
Future<void> signIn({required String email, required String password}) async {
|
||||
final int epoch = _epoch;
|
||||
final AppLoginResult result = await _repository.login(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
await _apply(result, epoch: epoch, email: email, password: password);
|
||||
}
|
||||
|
||||
/// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절).
|
||||
///
|
||||
/// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다.
|
||||
Future<void> submitCode(String otpCode) async {
|
||||
final ({String email, String password})? held = _secret.read();
|
||||
if (held == null) {
|
||||
state = const AsyncData<SessionState>(
|
||||
SignedOut(reason: SignOutReason.codeTimedOut),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final int epoch = _epoch;
|
||||
final AppLoginResult result;
|
||||
try {
|
||||
result = await _repository.login(
|
||||
email: held.email,
|
||||
password: held.password,
|
||||
otpCode: otpCode,
|
||||
);
|
||||
} on DioException catch (error) {
|
||||
// 그사이 [다른 계정으로 로그인] · 로그아웃했으면 지금 화면을 건드리지 않는다
|
||||
if (epoch == _epoch &&
|
||||
ref.mounted &&
|
||||
apiErrorCodeOf(error) == 'AUTH_004') {
|
||||
_secret.clear();
|
||||
state = const AsyncData<SessionState>(SignedOut());
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
await _apply(
|
||||
result,
|
||||
epoch: epoch,
|
||||
email: held.email,
|
||||
password: held.password,
|
||||
);
|
||||
}
|
||||
|
||||
/// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고,
|
||||
/// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다.
|
||||
Future<void> switchAccount() async {
|
||||
if (_store.hasSession || state.value is SignedIn) {
|
||||
await signOut(reason: SignOutReason.switchAccount);
|
||||
return;
|
||||
}
|
||||
_epoch++;
|
||||
_secret.clear();
|
||||
state = const AsyncData<SessionState>(
|
||||
SignedOut(reason: SignOutReason.switchAccount),
|
||||
);
|
||||
}
|
||||
|
||||
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다.
|
||||
/// 서버에 닿지 못해도 이 기기는 로그아웃 상태다.
|
||||
Future<void> signOut({SignOutReason reason = SignOutReason.none}) async {
|
||||
_epoch++;
|
||||
_secret.clear();
|
||||
// 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다)
|
||||
final Future<SignOutTokens> clearing = _store.signOut();
|
||||
state = AsyncData<SessionState>(SignedOut(reason: reason));
|
||||
final SignOutTokens tokens = await clearing;
|
||||
unawaited(_unregisterPushQuietly());
|
||||
if (tokens.refreshToken == null && tokens.accessToken == null) return;
|
||||
try {
|
||||
await _repository.logout(
|
||||
refreshToken: tokens.refreshToken,
|
||||
accessToken: tokens.accessToken,
|
||||
);
|
||||
} on DioException {
|
||||
// 서버 세션은 쓰지 않으면 미사용 14일에 끝난다
|
||||
}
|
||||
}
|
||||
|
||||
/// 제한(`AUTH_010`)을 다시 맞춘다 — 웹 `recheckRestriction` 과 같은 결. 겹친 요청은 하나로 묶는다.
|
||||
///
|
||||
/// 세션을 인증 인터셉터 줄 밖에서 묻는다(인터셉터가 이 결과를 기다리는 중이라).
|
||||
/// - 제한이 있으면 그 단계로 옮긴다 → [RestrictionCheck.restricted]
|
||||
/// - 제한이 없다는데 `AUTH_010` 이면 옛 토큰이다 — 갱신해 새 토큰을 받는다 → [RestrictionCheck.renewed]
|
||||
/// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale]
|
||||
/// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown]
|
||||
Future<RestrictionCheck> recheck() {
|
||||
return _rechecking ??= _recheck().whenComplete(() => _rechecking = null);
|
||||
}
|
||||
|
||||
Future<RestrictionCheck> _recheck() async {
|
||||
final int epoch = _epoch;
|
||||
if (state.value is! SignedIn) return RestrictionCheck.stale;
|
||||
bool isCurrent() =>
|
||||
epoch == _epoch && ref.mounted && state.value is SignedIn;
|
||||
|
||||
AuthSessionInfo? info;
|
||||
try {
|
||||
info = await _repository.readSessionDirect();
|
||||
} on DioException catch (error) {
|
||||
if (!isCurrent()) return RestrictionCheck.stale;
|
||||
// 액세스 토큰이 끝났으면 갱신해 본다(아래) — 그 밖의 장애는 확인하지 못한 것이다
|
||||
if (apiErrorCodeOf(error) != 'AUTH_001') return RestrictionCheck.unknown;
|
||||
}
|
||||
if (!isCurrent()) return RestrictionCheck.stale;
|
||||
if (info != null && info.restriction != null) {
|
||||
_setSignedIn(info);
|
||||
return RestrictionCheck.restricted;
|
||||
}
|
||||
|
||||
final RefreshOutcome outcome = await _repository.refresh();
|
||||
if (!isCurrent()) return RestrictionCheck.stale;
|
||||
switch (outcome) {
|
||||
case RefreshSucceeded(:final Map<String, Object?>? body):
|
||||
final AuthSessionInfo? refreshed =
|
||||
AuthSessionInfo.tryParse(body) ?? info;
|
||||
if (refreshed == null) return RestrictionCheck.renewed;
|
||||
_setSignedIn(refreshed);
|
||||
return refreshed.restriction == null
|
||||
? RestrictionCheck.renewed
|
||||
: RestrictionCheck.restricted;
|
||||
case RefreshRejected():
|
||||
case RefreshDiscarded():
|
||||
// 세션 끝 흐름이 로그인 화면으로 옮긴다
|
||||
return RestrictionCheck.stale;
|
||||
case RefreshFailed():
|
||||
return RestrictionCheck.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SessionState> _restore() async {
|
||||
final int epoch = _epoch;
|
||||
final AuthSessionInfo info;
|
||||
try {
|
||||
info = await _repository.me(silent: true);
|
||||
} on DioException catch (error) {
|
||||
// 되살리는 사이 [다른 계정으로 로그인] · 로그아웃했다 — 그 상태를 그대로 둔다
|
||||
if (epoch != _epoch) return _currentSignedOut();
|
||||
final String? code = apiErrorCodeOf(error);
|
||||
if (code == 'AUTH_001' || code == 'AUTH_002') {
|
||||
await _clearQuietly();
|
||||
return const SignedOut();
|
||||
}
|
||||
// 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다
|
||||
rethrow;
|
||||
}
|
||||
if (epoch != _epoch) return _currentSignedOut();
|
||||
_afterSignedIn(info, wasReady: false);
|
||||
return SignedIn(info);
|
||||
}
|
||||
|
||||
/// 되살리는 사이 바뀐 로그아웃 상태 — build 의 결과가 그 상태를 덮어쓰지 않게 같은 값을 돌려준다
|
||||
SessionState _currentSignedOut() {
|
||||
final SessionState? current = state.value;
|
||||
return current is SignedOut ? current : const SignedOut();
|
||||
}
|
||||
|
||||
Future<void> _apply(
|
||||
AppLoginResult result, {
|
||||
required int epoch,
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
if (!ref.mounted || epoch != _epoch) {
|
||||
// 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다
|
||||
final AuthTokens? tokens = result.tokens;
|
||||
if (tokens != null) unawaited(_logoutQuietly(tokens));
|
||||
return;
|
||||
}
|
||||
switch (result.status) {
|
||||
case LoginStatus.twoFactorRequired:
|
||||
_secret.hold(email, password);
|
||||
state = AsyncData<SessionState>(CodeRequired(email));
|
||||
case LoginStatus.signedIn:
|
||||
final AuthTokens tokens = result.tokens!;
|
||||
// 저장은 메모리를 곧바로 바꾸고 세대를 올린다 — 이 저장의 세대는 기다리기 전에 읽는다
|
||||
final Future<void> saving = _store.save(tokens);
|
||||
final int savedGeneration = _store.generation;
|
||||
try {
|
||||
await saving;
|
||||
} on PlatformException {
|
||||
// 받은 토큰을 둘 곳이 없다 — 이 기기의 토큰을 지우고 그 세션도 끝낸 뒤 실패로 알린다
|
||||
if (_store.generation == savedGeneration) await _clearQuietly();
|
||||
unawaited(_logoutQuietly(tokens));
|
||||
throw apiFailure(
|
||||
RequestOptions(path: ApiPaths.appLogin),
|
||||
kMalformedResponseCode,
|
||||
);
|
||||
}
|
||||
if (!ref.mounted || epoch != _epoch) {
|
||||
// 저장하는 사이 로그아웃했다 — 그 뒤에 다른 일이 없었으면 방금 둔 토큰을 지우고, 그 세션은 끝낸다
|
||||
if (_store.generation == savedGeneration) await _clearQuietly();
|
||||
unawaited(_logoutQuietly(tokens));
|
||||
return;
|
||||
}
|
||||
final AuthSessionInfo info = result.session!;
|
||||
if (info.restriction != null) {
|
||||
// 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다
|
||||
_secret.hold(email, password);
|
||||
}
|
||||
_setSignedIn(info);
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다
|
||||
void _setSignedIn(AuthSessionInfo info) {
|
||||
final SessionState? previous = state.value;
|
||||
final bool wasReady =
|
||||
previous is SignedIn && previous.info.restriction == null;
|
||||
state = AsyncData<SessionState>(SignedIn(info));
|
||||
_afterSignedIn(info, wasReady: wasReady);
|
||||
}
|
||||
|
||||
void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) {
|
||||
if (info.restriction != null) return;
|
||||
// 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일")
|
||||
_secret.clear();
|
||||
if (!wasReady) unawaited(_registerPush());
|
||||
}
|
||||
|
||||
Future<void> _registerPush() async {
|
||||
try {
|
||||
await _push.register();
|
||||
} on Object {
|
||||
// 알림 등록이 안 돼도 로그인은 그대로다 — 다음 로그인 · 되살리기 때 다시 한다
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unregisterPushQuietly() async {
|
||||
try {
|
||||
await _push.unregister().timeout(kPushUnregisterTimeout);
|
||||
} on Object {
|
||||
// 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(SessionEvent event) {
|
||||
final SessionState? current = state.value;
|
||||
switch (event) {
|
||||
case SessionRecheckNeeded():
|
||||
if (current is! SignedIn) {
|
||||
event.reply(RestrictionCheck.stale);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
recheck().then<void>(
|
||||
event.reply,
|
||||
onError: (Object _, StackTrace _) =>
|
||||
event.reply(RestrictionCheck.unknown),
|
||||
),
|
||||
);
|
||||
case SessionEnded():
|
||||
if (current is! SignedIn) return;
|
||||
_epoch++;
|
||||
_secret.clear();
|
||||
state = const AsyncData<SessionState>(
|
||||
SignedOut(reason: SignOutReason.expired),
|
||||
);
|
||||
case SessionRefreshed(:final Map<String, Object?> body):
|
||||
// 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다)
|
||||
if (current is! SignedIn) return;
|
||||
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
|
||||
if (info != null &&
|
||||
info.email == current.info.email &&
|
||||
info != current.info) {
|
||||
_setSignedIn(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logoutQuietly(AuthTokens tokens) async {
|
||||
try {
|
||||
await _repository.logout(
|
||||
refreshToken: tokens.refreshToken,
|
||||
accessToken: tokens.accessToken,
|
||||
);
|
||||
} on DioException {
|
||||
// 닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearQuietly() async {
|
||||
try {
|
||||
await _store.clear();
|
||||
} on PlatformException {
|
||||
// 메모리의 토큰은 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 지운다
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
|
||||
|
||||
/// 세션 상태 — 라우터의 redirect 가 이것으로 화면을 고른다(core/router/session_redirect.dart).
|
||||
///
|
||||
/// "되살리는 중"은 상태가 아니라 세션 노티파이어의 `AsyncLoading` 이고, 되살리기가 장애로 실패하면 `AsyncError` 다.
|
||||
sealed class SessionState {
|
||||
const SessionState();
|
||||
}
|
||||
|
||||
/// 로그아웃 상태 — 로그인 화면
|
||||
final class SignedOut extends SessionState {
|
||||
const SignedOut({this.reason = SignOutReason.none});
|
||||
|
||||
/// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다)
|
||||
final SignOutReason reason;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SignedOut && other.reason == reason;
|
||||
|
||||
@override
|
||||
int get hashCode => reason.hashCode;
|
||||
}
|
||||
|
||||
/// 로그아웃한 까닭
|
||||
enum SignOutReason {
|
||||
/// 처음 · 스스로 로그아웃
|
||||
none,
|
||||
|
||||
/// 세션이 끝났다(`AUTH_002` — 갱신 거절 · 다른 곳에서 끊음)
|
||||
expired,
|
||||
|
||||
/// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.")
|
||||
codeTimedOut,
|
||||
|
||||
/// [다른 계정으로 로그인]
|
||||
switchAccount,
|
||||
}
|
||||
|
||||
/// 비밀번호는 맞고 2단계 인증 코드를 기다린다 — 비밀번호는 상태에 두지 않는다(노티파이어 메모리에만 10분)
|
||||
final class CodeRequired extends SessionState {
|
||||
const CodeRequired(this.email);
|
||||
|
||||
final String email;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CodeRequired && other.email == email;
|
||||
|
||||
@override
|
||||
int get hashCode => email.hashCode;
|
||||
}
|
||||
|
||||
/// 로그인했다 — 제한이 있으면 그 단계 화면, 없으면 메일
|
||||
final class SignedIn extends SessionState {
|
||||
const SignedIn(this.info);
|
||||
|
||||
final AuthSessionInfo info;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is SignedIn && other.info == info;
|
||||
|
||||
@override
|
||||
int get hashCode => info.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 메일 모듈의 경로 — 모두 `/mail` 아래다
|
||||
abstract final class MailRoutes {
|
||||
/// 첫 화면 — 받은편지함 목록 (탭 안)
|
||||
static final String home = findAppModule(AppModuleId.mail).rootPath;
|
||||
|
||||
/// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다)
|
||||
static String mailbox(String mailboxId) =>
|
||||
'$home/mailboxes/${Uri.encodeComponent(mailboxId)}';
|
||||
|
||||
/// 메일 읽기 (전체 화면 — 탭 막대 없이)
|
||||
static String message(String messageId) =>
|
||||
'$home/messages/${Uri.encodeComponent(messageId)}';
|
||||
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
|
||||
static String get compose => '$home/compose';
|
||||
}
|
||||
|
||||
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
|
||||
///
|
||||
/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
|
||||
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
id: AppModuleId.mail,
|
||||
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
|
||||
locationOfItem: MailRoutes.message,
|
||||
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
|
||||
GoRoute(
|
||||
path: MailRoutes.home,
|
||||
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
|
||||
title: MailTexts.home,
|
||||
links: <PlaceholderLink>[
|
||||
PlaceholderLink(
|
||||
label: MailTexts.compose,
|
||||
location: MailRoutes.compose,
|
||||
),
|
||||
],
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'mailboxes/:mailboxId',
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.mailbox),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'messages/:messageId',
|
||||
parentNavigatorKey: context.rootNavigatorKey,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.message),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'compose',
|
||||
parentNavigatorKey: context.rootNavigatorKey,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.compose),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
|
||||
///
|
||||
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준).
|
||||
abstract final class MailTexts {
|
||||
/// 메일 첫 화면 — 받은편지함 목록
|
||||
static const String home = '메일';
|
||||
|
||||
/// 다른 메일함의 목록 (탭 안)
|
||||
static const String mailbox = '메일함';
|
||||
|
||||
/// 메일 읽기 (전체 화면)
|
||||
static const String message = '메일 읽기';
|
||||
|
||||
/// 메일 쓰기 (전체 화면)
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
|
||||
///
|
||||
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤).
|
||||
abstract final class SettingsTexts {
|
||||
/// 설정 첫 화면
|
||||
static const String home = '설정';
|
||||
|
||||
/// 내 정보 — 이름 · 주소
|
||||
static const String account = '내 정보';
|
||||
|
||||
/// 알림 켜기 · 끄기
|
||||
static const String notifications = '알림';
|
||||
|
||||
/// 앱 정보 — 판 · 오픈소스 라이선스
|
||||
static const String about = '앱 정보';
|
||||
|
||||
/// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry)
|
||||
static const String licenses = '오픈소스 라이선스';
|
||||
|
||||
/// 문의 연락처 (`GET /api/support-contact`)
|
||||
static const String support = '문의 연락처';
|
||||
|
||||
/// 로그아웃 (화면이 아니라 하는 일)
|
||||
static const String signOut = '로그아웃';
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/constants/app_constants.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 설정 모듈의 경로 — 모두 `/settings` 아래다
|
||||
abstract final class SettingsRoutes {
|
||||
/// 첫 화면 — 설정 목록 (탭 안)
|
||||
static final String home = findAppModule(AppModuleId.settings).rootPath;
|
||||
|
||||
/// 내 정보 — 이름 · 주소
|
||||
static String get account => '$home/account';
|
||||
|
||||
/// 알림 켜기 · 끄기
|
||||
static String get notifications => '$home/notifications';
|
||||
|
||||
/// 앱 정보 — 판 · 오픈소스 라이선스
|
||||
static String get about => '$home/about';
|
||||
|
||||
/// 오픈소스 라이선스 (전체 화면)
|
||||
static String get licenses => '$about/licenses';
|
||||
|
||||
/// 문의 연락처
|
||||
static String get support => '$home/support';
|
||||
|
||||
/// 다른 모듈이 내놓은 설정 절 (`/settings/<path>`)
|
||||
static String section(String path) => '$home/$path';
|
||||
}
|
||||
|
||||
/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
|
||||
///
|
||||
/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃.
|
||||
/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다.
|
||||
final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
|
||||
id: AppModuleId.settings,
|
||||
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
|
||||
GoRoute(
|
||||
path: SettingsRoutes.home,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
_SettingsHomePlaceholder(sections: context.settingsSections),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'account',
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: SettingsTexts.account),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'notifications',
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: SettingsTexts.notifications),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'about',
|
||||
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
|
||||
title: SettingsTexts.about,
|
||||
links: <PlaceholderLink>[
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.licenses,
|
||||
location: SettingsRoutes.licenses,
|
||||
),
|
||||
],
|
||||
),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'licenses',
|
||||
parentNavigatorKey: context.rootNavigatorKey,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const _LicensesPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'support',
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: SettingsTexts.support),
|
||||
),
|
||||
for (final ModuleSettingsSection section in context.settingsSections)
|
||||
GoRoute(path: section.path, builder: section.builder),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 설정 첫 화면 자리 — 절 이름과 [로그아웃]
|
||||
class _SettingsHomePlaceholder extends ConsumerWidget {
|
||||
const _SettingsHomePlaceholder({required this.sections});
|
||||
|
||||
final List<ModuleSettingsSection> sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return PlaceholderPage(
|
||||
title: SettingsTexts.home,
|
||||
links: <PlaceholderLink>[
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.account,
|
||||
location: SettingsRoutes.account,
|
||||
),
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.notifications,
|
||||
location: SettingsRoutes.notifications,
|
||||
),
|
||||
for (final ModuleSettingsSection section in sections)
|
||||
PlaceholderLink(
|
||||
label: section.title,
|
||||
location: SettingsRoutes.section(section.path),
|
||||
),
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.about,
|
||||
location: SettingsRoutes.about,
|
||||
),
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.support,
|
||||
location: SettingsRoutes.support,
|
||||
),
|
||||
PlaceholderLink(
|
||||
label: SettingsTexts.signOut,
|
||||
onTap: () => unawaited(ref.read(sessionProvider.notifier).signOut()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 오픈소스 라이선스 — Flutter 라이선스 화면(LicenseRegistry — Pretendard OFL 포함)
|
||||
class _LicensesPage extends ConsumerWidget {
|
||||
const _LicensesPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return LicensePage(
|
||||
applicationName: kAppName,
|
||||
applicationVersion: ref.watch(appVersionProvider),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,24 @@
|
||||
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
|
||||
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
|
||||
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
|
||||
///
|
||||
/// 모델은 손으로 쓴다 — 생성기(freezed · json_serializable)를 넣을 때의 주의는 `flutter/README.md` "build_runner".
|
||||
class UserModel {
|
||||
const UserModel({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
});
|
||||
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) {
|
||||
factory UserModel.fromJson(Map<String, Object?> json) {
|
||||
return UserModel(
|
||||
id: json['id'] as int,
|
||||
email: json['email'] as String,
|
||||
name: json['name'] as String,
|
||||
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,
|
||||
};
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:arasystem_app/features/user/data/models/user_model.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/user_model.dart';
|
||||
|
||||
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
|
||||
/// 사용자 API 호출 구현체 — 목록 페이지네이션(`{items, pageInfo}`)의 예제다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
|
||||
///
|
||||
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
|
||||
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
|
||||
@@ -12,7 +11,8 @@ class UserRepository {
|
||||
final Dio _dio;
|
||||
|
||||
Future<UserModel> getUser(int id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
|
||||
final Response<Map<String, Object?>> response = await _dio
|
||||
.get<Map<String, Object?>>('/users/$id');
|
||||
return UserModel.fromJson(response.data!);
|
||||
}
|
||||
|
||||
@@ -21,19 +21,20 @@ class UserRepository {
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/users',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'cursor': ?cursor,
|
||||
},
|
||||
);
|
||||
final Response<Map<String, Object?>> response = await _dio
|
||||
.get<Map<String, Object?>>(
|
||||
'/users',
|
||||
queryParameters: <String, Object?>{'limit': limit, 'cursor': ?cursor},
|
||||
);
|
||||
|
||||
final data = response.data!;
|
||||
final items = (data['items'] as List<dynamic>)
|
||||
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
|
||||
final Map<String, Object?> data = response.data!;
|
||||
final List<UserModel> items = (data['items']! as List<Object?>)
|
||||
.map(
|
||||
(Object? json) => UserModel.fromJson(json! as Map<String, Object?>),
|
||||
)
|
||||
.toList();
|
||||
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
|
||||
final Map<String, Object?> pageInfo =
|
||||
data['pageInfo']! as Map<String, Object?>;
|
||||
|
||||
return (
|
||||
items: items,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/features/user/data/models/user_model.dart';
|
||||
import 'package:arasystem_app/features/user/data/repositories/user_repository.dart';
|
||||
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';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
// 리포지토리 Provider — dioProvider 를 주입받아 생성
|
||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||
return UserRepository(ref.watch(dioProvider));
|
||||
});
|
||||
/// 리포지토리 Provider — dioProvider 를 주입받아 생성 (템플릿 예제)
|
||||
final Provider<UserRepository> userRepositoryProvider =
|
||||
Provider<UserRepository>(
|
||||
(Ref ref) => 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);
|
||||
});
|
||||
/// 특정 사용자 조회 Provider (family)
|
||||
/// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
|
||||
final FutureProviderFamily<UserModel, int> userProvider =
|
||||
FutureProvider.family<UserModel, int>(
|
||||
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user