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:
2026-11-06 11:11:28 +09:00
parent 5823f600aa
commit 35fcf5e4ef
82 changed files with 7339 additions and 324 deletions
@@ -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),
);